There are elements in Active Directory that we completely forget about because they never come up in day-to-day operations. The krbtgt account is one of them. Though invisible in everyday use, it is at the heart of all Kerberos authentication in your domain—and its compromise is one of the most catastrophic scenarios an attacker could trigger.

In this article, we’ll explore what this account really is, why you need to change its password regularly, and how to automate this process cleanly with Ansible.


Kerberos 101: What the KDC Does

Before discussing krbtgt, here’s a quick refresher on how Kerberos works in an Active Directory domain.

When a user connects to a domain resource (a file server, a web app, or any service), they never transmit their password directly. Instead, the Kerberos process unfolds in three steps:

  1. AS-REQ / AS-REP: The client contacts the KDC (Key Distribution Center, i.e., the domain controller) to request a TGT (Ticket-Granting Ticket). If the credentials are valid, the KDC returns the TGT, encrypted and signed.

  2. TGS-REQ / TGS-REP: With its TGT in hand, the client requests a service ticket (ST) for the target resource.

  3. AP-REQ: The client presents its service ticket directly to the resource, which can validate it without going back through the KDC.

This mechanism is elegant: the resource does not have to contact the DC with every connection, and the user’s password never travels in plain text over the network.


The krbtgt account: the cornerstone

The krbtgt account is a special, disabled service account created automatically when a domain controller is promoted. Its role is unique and fundamental: its password is used by the KDC to encrypt and sign all TGTs issued in the domain.

In other words, every Kerberos ticket in your domain bears the imprint of the key derived from the krbtgt password. When a domain controller receives a TGT to validate, it does not consult a database—it simply verifies that the ticket was indeed signed with its krbtgt key.

┌─────────────────────────────────────────────────────────┐
│                   Domain controller                  │
│                                                         │
│  [ krbtgt account ]                                      │
│  Password hash ──► TGT encryption key   │
│                                                         │
│  Any valid TGT = signed with this key                │
└─────────────────────────────────────────────────────────┘

This account never logs in, never authenticates, and never sends any network traffic. It exists solely as cryptographic hardware.


The Golden Ticket Attack: When krbtgt Is Compromised

If an attacker manages to extract the NTLM hash of the krbtgt password, they can forge valid TGTs from scratch—without going through the KDC, for any user, with any groups, and for any validity period.

This is known as the Golden Ticket.

# Example using Mimikatz (for educational purposes)
# Extracting the krbtgt hash on a compromised DC
lsadump::lsa /patch

# Forging a Golden Ticket valid for 10 years for "Administrator"
kerberos::golden /user:Administrator /domain:mydomain.local 
  /sid:S-1-5-21-... /krbtgt:<hash_ntlm> /endin:87600

With this ticket:

  • The attacker is indistinguishable from a legitimate administrator in the eyes of all domain member servers
  • The ticket remains valid even if the actual administrator account is disabled or renamed
  • The ticket remains valid as long as the krbtgt password has not been changed
  • The attack persists even after reinstalling the compromised machine (as long as the domain is running)

This is one of the few attacks in Active Directory that provides absolute persistence. Hence the critical importance of regularly changing this account’s password.

> Important note: Microsoft stores the last two passwords for the krbtgt account (the current one and the previous one). Tickets signed with either one remain valid. This is why you must change the password twice to completely invalidate the old tickets.


Why don’t we do this more often?

The honest answer: out of fear. And this fear is partly justified.

Changing the krbtgt password triggers replication to all domain controllers in the domain. During the period when some DCs have the new password and others do not yet, authentications may fail.

Common problem scenarios:

  • Multi-site environments with slow WAN links
  • DCs that are offline or undergoing maintenance at the time of the change
  • Kerberos tickets currently in use with a long validity period

In practice, in a well-maintained environment (synchronized DCs, reasonable replication latency), the change occurs without any visible issues. Microsoft recommends waiting at least 10 hours between the two rotations—this is the default maximum validity period for a Kerberos ticket (10 hours of UserLogonLifetime).


There is no absolute consensus, but the general recommendations are as follows:

Context Suggested Frequency
Standard environment Every 180 days
Post-incident (suspected compromise) Immediately, twice at 10-hour intervals
Termination of a former employee with DC access Immediately
After a DC migration Recommended
After a DC restore from backup Mandatory

Microsoft has also released an official PowerShell script (New-KrbtgtKeys.ps1) that has become the standard for this operation. We will use it as the basis on the Windows side and orchestrate it via Ansible.


Ansible Solution Architecture

The idea is simple:

Ansible Node (Linux)
        │
        │ WinRM (HTTPS)
        ▼
Primary Domain Controller (PDC Emulator)
        │
        │ AD Replication
        ▼
Other Domain Controllers

The playbook will:

  1. Target the PDC Emulator (the correct location for AD password changes)
  2. Download or upload the official Microsoft script
  3. Perform an initial rotation
  4. Wait 10 hours (or a configurable delay)
  5. Perform a second rotation
  6. Verify replication on all DCs

Prerequisites

On the Ansible controller side:

# Required Windows module
pip install pywinrm
ansible-galaxy collection install ansible.windows

On the Windows Server side (the DCs):

# Enable WinRM (if not already done via GPO)
Enable-PSRemoting -Force
winrm set winrm/config/service/auth &#x27;@{Basic=&quot;true&quot;}&#x27;
winrm set winrm/config/service &#x27;@{AllowUnencrypted=&quot;false&quot;}&#x27;

The Ansible playbook

File structure

krbtgt-rotation/
├── inventory/
│   └── production.yml
├── roles/
│   └── krbtgt_rotation/
│       ├── tasks/
│       │   └── main.yml
│       ├── files/
│       │   └── New-KrbtgtKeys.ps1
│       └── defaults/
│           └── main.yml
└── site.yml

Inventory

# inventory/production.yml
all:
  children:
    domain_controllers:
      hosts:
        dc01.mydomain.local:
          ansible_host: 192.168.1.10
        dc02.mydomain.local:
          ansible_host: 192.168.1.11
      vars:
        ansible_user: &quot;MYDOMAIN\\ansible_svc&quot;
        ansible_password: &quot;{{ vault_ansible_password }}&quot;
        ansible_connection: winrm
        ansible_winrm_transport: kerberos
        ansible_winrm_server_cert_validation: validate
        ansible_port: 5986

> Best practice: Use ansible-vault to encrypt credentials. Never include a password in plain text in an inventory file.

# Encrypting the vault
ansible-vault create group_vars/all/vault.yml
# Content:
# vault_ansible_password: &quot;YourAnsibleServicePassword&quot;

Role defaults

# roles/krbtgt_rotation/defaults/main.yml

# Time between rotations (in seconds)
# 36000 = 10 hours (Microsoft recommendation)
# For testing, you can reduce this to 60 seconds
krbtgt_rotation_delay: 36000

# Mode: &#x27;simulate&#x27; (dry-run) or &#x27;reset&#x27; (actual rotation)
krbtgt_operation_mode: &quot;simulate&quot;

# Temporary path on the DC for the script
krbtgt_script_dest: &quot;C:\\Windows\\Temp\\New-KrbtgtKeys.ps1&quot;

# Target domain (will be detected automatically if empty)
krbtgt_domain: &quot;&quot;

Main tasks

# roles/krbtgt_rotation/tasks/main.yml
---

# ─── Pre-checks ───────────────────────────────────────────────────────

- name: Check that the ActiveDirectory module is available
  ansible.windows.win_powershell:
    script: |
      if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
          throw &quot;ActiveDirectory module not available. Install RSAT.&quot;
      }
      Write-Output &quot;OK&quot;      
  register: ad_module_check
  changed_when: false

- name: Identify the domain PDC Emulator
  ansible.windows.win_powershell:
    script: |
      $pdc = (Get-ADDomain).PDCEmulator
      Write-Output $pdc      
  register: pdc_emulator
  run_once: true
  changed_when: false

- name: Display the identified PDC Emulator
  ansible.builtin.debug:
    msg: &quot;PDC Emulator: {{ pdc_emulator.output[0] }}&quot;
  run_once: true

# ─── Script Deployment ───────────────────────────────────────────────────

- name: Copy the New-KrbtgtKeys.ps1 script to the DC
  ansible.windows.win_copy:
    src: New-KrbtgtKeys.ps1
    dest: &quot;{{ krbtgt_script_dest }}&quot;
  when: inventory_hostname == pdc_emulator.output[0].split(&#x27;.&#x27;)[0]

# ─── Checking the current status ──────────────────────────────────────────

- name: Check the date of the last krbtgt password change
  ansible.windows.win_powershell:
    script: |
      $krbtgt = Get-ADUser krbtgt -Properties PasswordLastSet, msDS-KeyVersionNumber
      $result = @{
          PasswordLastSet   = $krbtgt.PasswordLastSet.ToString(&quot;yyyy-MM-dd HH:mm:ss&quot;)
          KeyVersionNumber  = $krbtgt.&#x27;msDS-KeyVersionNumber&#x27;
          DaysSinceChange   = ((Get-Date) - $krbtgt.PasswordLastSet).Days
      }
      Write-Output ($result | ConvertTo-Json)      
  register: krbtgt_current_state
  run_once: true
  changed_when: false
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;

- name: Display the current state of the krbtgt account
  ansible.builtin.debug:
    msg: &quot;{{ krbtgt_current_state.output[0] | from_json }}&quot;
  run_once: true

# ─── First rotation ───────────────────────────────────────────────────────

- name: &quot;First krbtgt rotation (mode: {{ krbtgt_operation_mode }})&quot;
  ansible.windows.win_powershell:
    script: |
      $params = @{
          OperationMode = &quot;{{ krbtgt_operation_mode }}&quot;
          DomainFQDN    = (Get-ADDomain).DNSRoot
      }
      &amp; &quot;{{ krbtgt_script_dest }}&quot; @params      
  register: rotation_first
  run_once: true
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;
  when: krbtgt_operation_mode == &quot;reset&quot;

- name: Log of the first rotation
  ansible.builtin.debug:
    msg: &quot;{{ rotation_first.output }}&quot;
  run_once: true
  when: krbtgt_operation_mode == &quot;reset&quot;

# ─── Checking replication before waiting ────────────────────────

- name: Force AD replication to all DCs
  ansible.windows.win_powershell:
    script: |
      repadmin /syncall /AdeP
      Start-Sleep -Seconds 30
      repadmin /showrepl      
  register: repl_check_1
  run_once: true
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;
  when: krbtgt_operation_mode == &quot;reset&quot;

- name: Check that replication has no errors
  ansible.windows.win_powershell:
    script: |
      $errors = repadmin /showrepl 2&gt;&amp;1 | Select-String &quot;fail|error&quot; -CaseSensitive:$false
      if ($errors) {
          Write-Warning &quot;Replication errors detected:&quot;
          $errors | ForEach-Object { Write-Warning $_.Line }
          # Log but do not block - the admin must investigate
      } else {
          Write-Output &quot;Replication OK&quot;
      }      
  register: repl_errors
  run_once: true
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;
  changed_when: false

# ─── Inter-rotation wait ──────────────────────────────────────────────────

- name: &quot;Wait {{ krbtgt_rotation_delay }} seconds between rotations&quot;
  ansible.builtin.pause:
    seconds: &quot;{{ krbtgt_rotation_delay }}&quot;
  run_once: true
  when: krbtgt_operation_mode == &quot;reset&quot;

# ─── Second rotation ────────────────────────────────────────────────────────

- name: &quot;Second krbtgt rotation (permanently invalidating old tickets)&quot;
  ansible.windows.win_powershell:
    script: |
      $params = @{
          OperationMode = &quot;{{ krbtgt_operation_mode }}&quot;
          DomainFQDN    = (Get-ADDomain).DNSRoot
      }
      &amp; &quot;{{ krbtgt_script_dest }}&quot; @params      
  register: rotation_second
  run_once: true
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;
  when: krbtgt_operation_mode == &quot;reset&quot;

- name: Log of the second rotation
  ansible.builtin.debug:
    msg: &quot;{{ rotation_second.output }}&quot;
  run_once: true
  when: krbtgt_operation_mode == &quot;reset&quot;

# ─── Post-rotation verification ──────────────────────────────────────────────

- name: Verify the new Kerberos version number (msDS-KeyVersionNumber)
  ansible.windows.win_powershell:
    script: |
      $krbtgt = Get-ADUser krbtgt -Properties PasswordLastSet, msDS-KeyVersionNumber
      $result = @{
          PasswordLastSet  = $krbtgt.PasswordLastSet.ToString(&quot;yyyy-MM-dd HH:mm:ss&quot;)
          KeyVersionNumber = $krbtgt.&#x27;msDS-KeyVersionNumber&#x27;
      }
      Write-Output ($result | ConvertTo-Json)      
  register: krbtgt_new_state
  run_once: true
  changed_when: false
  delegate_to: &quot;{{ pdc_emulator.output[0] }}&quot;

- name: Post-rotation summary
  ansible.builtin.debug:
    msg:
      - &quot;=== krbtgt rotation complete ===&quot;
      - &quot;New state: {{ krbtgt_new_state.output[0] | from_json }}&quot;
      - &quot;Remember to document this rotation in your CMDB.&quot;
  run_once: true

# ─── Cleanup ───────────────────────────────────────────────────────────────

- name: Remove the script from the DC after use
  ansible.windows.win_file:
    path: &quot;{{ krbtgt_script_dest }}&quot;
    state: absent
  when: inventory_hostname == pdc_emulator.output[0].split(&#x27;.&#x27;)[0]

Main Playbook

# site.yml
---
- name: Rotate Active Directory krbtgt password
  hosts: domain_controllers
  gather_facts: false

  vars_files:
    - group_vars/all/vault.yml

  pre_tasks:
    - name: Display execution mode
      ansible.builtin.debug:
        msg: &gt;
          Mode: {{ krbtgt_operation_mode | upper }}
          {% if krbtgt_operation_mode == &#x27;simulate&#x27; %}
          (DRY-RUN - no changes will be made)
          {% else %}
          ⚠️  ACTUAL ROTATION - passwords will be changed
          {% endif %}
      run_once: true

  roles:
    - role: krbtgt_rotation

Execution

# Verification without any changes
ansible-playbook site.yml \
  --ask-vault-pass \
  -e &quot;krbtgt_operation_mode=simulate&quot;

The script will display the current account status, the DCs that would be affected, and simulate the process without making any changes.

Actual rotation mode

# Rotation with a full 10-hour delay between the two passes
ansible-playbook site.yml \
  --ask-vault-pass \
  -e &quot;krbtgt_operation_mode=reset&quot; \
  -e &quot;krbtgt_rotation_delay=36000&quot;

Accelerated rotation mode (lab testing)

# 60-second delay - ONLY in a test environment
ansible-playbook site.yml \
  --ask-vault-pass \
  -e &quot;krbtgt_operation_mode=reset&quot; \
  -e &quot;krbtgt_rotation_delay=60&quot;

What to monitor after rotation

Once rotation is complete, the metrics to check:

AD Replication

# On each DC, verify that the version number is identical
Get-ADReplicationUpToDatenessVectorTable -Target &quot;dc01.mydomain.local&quot;

# Check the krbtgt kvno on all DCs
repadmin /showobjmeta * &quot;CN=krbtgt,CN=Users,DC=mydomain,DC=local&quot; | 
  Select-String &quot;pwdLastSet|msDS-KeyVersionNumber&quot;

Current Kerberos Tickets

After the first rotation, existing tickets remain valid (Windows retains the old password). After the second rotation, all old tickets signed with the previous krbtgt become invalid.

Users will simply need to re-authenticate transparently the next time they access a resource—in the vast majority of cases, they won’t even notice it.

Windows Events to Monitor

In the Event Viewer on the DCs:

  • Event ID 4723: Password change attempt
  • Event ID 4724: password reset by an admin
  • Event ID 4769: Kerberos ticket request (to detect unusual TGSs after rotation)

Integration into a security pipeline

To take it further, this rotation can be integrated into a broader pipeline:

Scheduler (cron/AWX/Semaphore)
        │
        ├─► krbtgt rotation (this playbook)
        ├─► Slack/Teams/Email notification
        └─► Write to CMDB/security log

Example of a Slack notification at the end of the playbook:

- name: Rotation completion notification
  community.general.slack:
    token: &quot;{{ vault_slack_token }}&quot;
    channel: &quot;#soc-alerts&quot;
    msg: |
      ✅ *Krbtgt rotation completed*
      Domain: {{ ansible_domain }}
      Date: {{ ansible_date_time.iso8601 }}
      KeyVersionNumber: {{ krbtgt_new_state.output[0] | from_json | json_query(&#x27;KeyVersionNumber&#x27;) }}      
  run_once: true
  delegate_to: localhost

Multi-domain environments

If, like me, you have multiple AD domains (infrastructure + users), the rotation must be performed independently in each domain. The krbtgt account is local to each domain—there is no "global" krbtgt that spans multiple domains, even if they are linked by an approval relationship.

In this case, duplicate the inventory:

# inventory/production.yml
all:
  children:
    domain_infra:
      hosts:
        dc-infra-01.infradomain.local:
      vars:
        ansible_user: &quot;INFRA\\ansible_svc&quot;
        # ...

    domain_users:
      hosts:
        dc-users-01.userdomaine.local:
      vars:
        ansible_user: &quot;USERS\\ansible_svc&quot;
        # ...

And run the playbook twice, targeting each group.


In summary

The krbtgt account is to Active Directory what the master key is to a building: if it falls into the wrong hands, all the locks are useless. Rotating it regularly is one of the simplest and most effective measures to limit the duration of a compromise.

With Ansible, the operation becomes repeatable, traceable, and above all unmissable—scheduled in a cron job or an AWX pipeline, it runs without human intervention, with logs and notifications.

Key points to remember:

  • Always perform two rotations spaced at least 10 hours apart
  • Always target the PDC Emulator for the change
  • Verify AD replication between the two passes
  • In a multi-domain environment, handle each domain separately
  • Test first in simulate mode before switching to reset

Sources and references: