In the first part of this article, we laid the groundwork: what the krbtgt account is, why the Golden Ticket is a serious threat, and a theoretical Ansible architecture for automating rotations. If you haven’t read that part, I encourage you to start there.

Now we’re getting down to business. The playbook in Part 1 was intentionally simplified to illustrate the concept. In a real production environment, things get complicated—the interactive script that refuses to be controlled, the XML file that can’t be found, the Kerberos double-hop that blocks everything, the root forest that isn’t what we think it is. These are all obstacles I’ve encountered and resolved, which I’m documenting here.


The Real Environment

My Active Directory infrastructure consists of two separate domains within the same forest:

  • infradomaine.local — the infrastructure domain, with two DCs (pdc-infra and dc-infra-02)
  • userdomaine.local — the user domain, with two DCs (pdc-users and dc-users-02)

The Ansible node is a Linux machine (Ubuntu 24.04) that orchestrates the entire setup.

srv-ansible (Ubuntu 24.04)
        │
        ├── WinRM/Kerberos (HTTPS:5986) ──► pdc-infra.infradomaine.local
        │                                         │
        │                                         └── AD Replication ──► dc-infra-02.infradomaine.local
        │
        └── WinRM/CredSSP (HTTPS:5986) ──► pdc-users.userdomaine.local
                                                  │
                                                  └── AD Replication ──► dc-users-02.userdomaine.local

> Why two different transports? We’ll come back to this—it’s one of the most interesting aspects of this implementation.


The Right Script: New-KrbtgtKeys.ps1 by Jorge de Almeida Pinto

In Part 1, I mentioned "an official Microsoft script." In practice, the script I use—and recommend—is the one by Jorge de Almeida Pinto, Enterprise Mobility & Security MVP, available on his GitHub repository:

👉 https://github.com/zjorz/Public-AD-Scripts

A big thank you to Jorge for this remarkable work. His script (New-KrbtgtKeys.ps1, currently at v3.9) is much more comprehensive than the basic version: simulation mode management, RODC support, replication verification across all DCs, automatic routine mode with tracking in AD attributes, email notifications… It’s a benchmark in the AD community.


First hurdle: an interactive script that’s hard to automate

Jorge’s script is designed to be used interactively. Each time it runs, it asks a series of questions:

Do you want to read information about the script? [YES | NO]:
Which mode of operation do you want to execute? [1-9 | 0]:
For the AD forest to be targeted, press [ENTER] for current:
For the AD domain to be targeted, press [ENTER] for current:
Which KrbTgt account do you want to target? [1 | 0]:
Do you really want to continue? [CONTINUE | STOP]:
What do you want to do? [CONTINUE | SKIP | STOP]:

Since v3.9, a -modeOfOperation parameter allows you to bypass the mode prompt. But all other prompts remain interactive. Since Ansible, there is no terminal—you can’t simply type in responses.

The False Good Idea: PowerShell Here-String

My first instinct was to use a here-string to pipe the answers:

# This doesn't work
@"
no


1
CONTINUE
CONTINUE
"@ | & "C:\Scripts\krbtgt\New-KrbtgtKeys.ps1" -modeOfOperation resetModeKrbTgtProdAccountsResetOnce

The problem: Jorge’s script uses Read-Host, not [Console]::ReadLine(). Read-Host does not read from standard stdin—it reads directly from the console handle. The PowerShell pipe does not work in this case.

The solution: stdin redirection via cmd.exe

The clean solution is to use cmd.exe with stdin redirection from a temporary file:

# Create the answers file
$answersFile = "C:\Scripts\krbtgt\krbtgt_answers.tmp"
$answersContent = "no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n"
[System.IO.File]::WriteAllText($answersFile, $answersContent, [System.Text.Encoding]::ASCII)

# Run the script with stdin redirected
$cmdArgs = "/c powershell.exe -ExecutionPolicy Bypass -File `"C:\Scripts\krbtgt\New-KrbtgtKeys.ps1`" " +
           "-modeOfOperation resetModeKrbTgtProdAccountsResetOnce " +
           "-skipDAMembershipCheck -skipElevationCheck < `"$answersFile`""
$output = & cmd.exe $cmdArgs 2>&1

# Cleanup
Remove-Item $answersFile -Force -ErrorAction SilentlyContinue

The < file redirection performed by cmd.exe operates at the operating system level, bypassing the behavior of Read-Host. The script successfully receives the responses in the correct order.

The response sequence for subdomain.local (from this domain’s PDC):

no          # Do not read the documentation
            # [Input] → current forest (subdomain.local)
            # [Input] → current domain (subdomain.local)
1           # Scope: all RWDCs
CONTINUE    # Confirm execution of mode 6
CONTINUE    # Confirm "MAJOR DOMAIN WIDE IMPACT"

> Important: The mode question (-modeOfOperation) is handled by the command-line parameter and does not require a response in the file. Do not include it in your sequence, as this may cause all subsequent responses to be shifted.


Second hurdle: the XML configuration file

Jorge’s script supports an XML configuration file that enables advanced features: automatic rotation routine with AD tracking, email sending, and configurable intervals.

In the logs, as long as the XML file is not found, you will see this:

Source Of Connection Parameters : Default Values In Script - No XML Config File Found
Use XML Config Settings         : FALSE
Reset Routine Enabled           : FALSE
Send Mail                       : FALSE

The catch: the XML file must have exactly the same name as the script, with the .xml extension. Not config.xml, not Reset-KrbTgt-Password-For-RWDCs-And-RODCs.xml (that’s the name of the example file on GitHub)—but New-KrbtgtKeys.xml, in the same folder as the script.

# In the script, line 6469:
$script:scriptXMLConfigFilePath = Join-Path $currentScriptFolderPath `
    $currentScriptFileName.Replace(".ps1", ".xml")

Once the file is correctly named and placed, the logs change:

Source Of Connection Parameters : XML Config File 'C:\Scripts\krbtgt\New-KrbtgtKeys.xml'
Use XML Config Settings         : TRUE
Reset Routine Enabled           : TRUE
Send Mail                       : TRUE

XML Configuration

Here are the key settings I use. The SMTP is an internal relay without authentication:

<?xml version="1.0" encoding="utf-8"?>

<resetKrbTgtPassword>
    <xmlReleaseVersion>v0.4</xmlReleaseVersion>
    
    <xmlReleaseDate>2026-01-15</xmlReleaseDate>

    <!-- Activer les paramètres XML -->
    
    <useXMLConfigFileSettings>TRUE</useXMLConfigFileSettings>
    

    <connectionTimeoutInMilliSeconds>500</connectionTimeoutInMilliSeconds>
    
    <goldenTicketMonitorWaitingIntervalBetweenRunsInSeconds>3600</goldenTicketMonitorWaitingIntervalBetweenRunsInSeconds>
    
    <goldenTicketMonitoringPeriodInSeconds>172800</goldenTicketMonitoringPeriodInSeconds>

    <!-- Routine de rotation automatique tous les 90 jours -->
    
    <resetRoutineEnabled>TRUE</resetRoutineEnabled>
    
    <resetRoutineFirstResetIntervalInDays>90</resetRoutineFirstResetIntervalInDays>
    
    <resetRoutineSecondResetIntervalInDays>1</resetRoutineSecondResetIntervalInDays>

    <!-- Attributs AD utilisés pour le tracking de la rotation -->
    
    <resetRoutineAttributeForResetState>extensionAttribute2</resetRoutineAttributeForResetState>
    
    <resetRoutineAttributeForResetDateAction1>extensionAttribute3</resetRoutineAttributeForResetDateAction1>
    
    <resetRoutineAttributeForResetDateAction2>extensionAttribute4</resetRoutineAttributeForResetDateAction2>

    <!-- Relay SMTP interne, sans authentification -->
    
    <sendMailEnabled>TRUE</sendMailEnabled>
    
    <smtpServer>smtp-relay.subdomain.local</smtpServer>
    
    <smtpPort>25</smtpPort>
    
    <useSSL>FALSE</useSSL>
    
    <smtpCredsType>NO_AUTHN</smtpCredsType>
    
    <smtpCredsUserName>NO_AUTHN</smtpCredsUserName>
    
    <smtpCredsPassword>NO_AUTHN</smtpCredsPassword>

    <mailSubject>[krbtgt] Rotation - infradomaine.local</mailSubject>
    
    <mailPriority>High</mailPriority>
    <mailBody><!-- ... corps HTML ... --></mailBody>
    
    <mailFromSender>krbtgt-rotation@infradomaine.local</mailFromSender>
    
    <mailToRecipients>
        <mailToRecipient>admin@infradomaine.local</mailToRecipient>
    </mailToRecipients>
    <mailCcRecipients>
        <mailCcRecipient />
    </mailCcRecipients>
</resetKrbTgtPassword>

> Note on smtpServer: the script validates this value and rejects IP addresses. A resolvable DNS name is required.


The final architecture: a PowerShell wrapper

Rather than putting all the logic in the Ansible playbook, I opted for a PowerShell wrapper on each DC. Ansible simply calls this wrapper, which handles the rest.

Advantages:

  • The wrapper can be tested directly on the DC without Ansible
  • The logic for log management and email sending is encapsulated on the Windows side
  • The Ansible playbook remains minimal and readable
srv-ansible
    │
    │ win_powershell: launches the wrapper
    ▼
pdc-infra.infradomaine.local
    │
    │ cmd.exe + stdin redirection
    ▼
New-KrbtgtKeys.ps1
    │
    ├── Krbtgt password rotation
    ├── Replication check on all DCs
    └── Send log via email using smtp-relay

The PowerShell wrapper (infradomaine.local version)

# Invoke-KrbtgtRotation-Infra.ps1
# Krbtgt rotation wrapper for infradomaine.local
# Place in C:\Scripts\krbtgt\ on the PDC

$ScriptPath = &quot;C:\Scripts\krbtgt\New-KrbtgtKeys.ps1&quot;
$LogDir     = &quot;C:\Scripts\krbtgt&quot;
$SmtpServer = &quot;smtp-relay.subdomain.local&quot;
$SmtpPort   = 25
$MailFrom   = &quot;krbtgt-rotation@infradomaine.local&quot;
$MailTo     = &quot;admin@infradomaine.local&quot;
$Domain     = &quot;subdomain.local&quot;

if (-not (Test-Path $ScriptPath)) {
    Write-Host &quot;ERROR: $ScriptPath not found.&quot; -ForegroundColor Red
    exit 1
}

Write-Host &quot;=== krbtgt rotation $Domain ===&quot; -ForegroundColor Cyan
Write-Host &quot;Date: $(Get-Date -Format &#x27;yyyy-MM-dd HH:mm:ss&#x27;)&quot;

# Responses to interactive questions:
# 1. &quot;Read info?&quot;          → no
# 2. &quot;Forest?&quot;             → [Enter] (current forest = subdomain.local)
# 3. &quot;Domain?&quot;             → [Enter] (current domain = subdomain.local)
# 4. &quot;Scope?&quot;              → 1 (all RWDCs)
# 5. &quot;Continue mode 6?&quot;    → CONTINUE
# 6. &quot;Major impact?&quot;       → CONTINUE
$answersFile    = &quot;$LogDir\krbtgt_answers.tmp&quot;
$answersContent = &quot;no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n&quot;
[System.IO.File]::WriteAllText($answersFile, $answersContent, [System.Text.Encoding]::ASCII)

$cmdArgs = &quot;/c powershell.exe -ExecutionPolicy Bypass -File `&quot;$ScriptPath`&quot; &quot; +
           &quot;-modeOfOperation resetModeKrbTgtProdAccountsResetOnce &quot; +
           &quot;-skipDAMembershipCheck -skipElevationCheck &lt; `&quot;$answersFile`&quot;&quot;
$output = &amp; cmd.exe $cmdArgs 2&gt;&amp;1

Remove-Item $answersFile -Force -ErrorAction SilentlyContinue
Write-Host $output

# Retrieve the most recent log generated by the script
$LogFile = Get-ChildItem &quot;$LogDir\*pdc-infra*New-KrbtgtKeys.log&quot; |
    Sort-Object LastWriteTime -Descending |
    Select-Object -First 1

if (-not $LogFile) {
    Write-Host &quot;No log found - rotation not due (scheduled for 90 days).&quot; -ForegroundColor Yellow
    exit 0
}

$LogContent = Get-Content $LogFile.FullName -Raw -Encoding UTF8
$Success    = ($LogContent -match &quot;Nr Of KrbTGT Account\(s\) With SUCCESSFUL Reset.*: 1&quot;)
$Failed     = ($LogContent -match &quot;Nr Of KrbTGT Account\(s\) With FAILED Reset.*: [^0]&quot;)

if ($Failed)      { $Status = &quot;FAIL&quot; }
elseif ($Success) { $Status = &quot;SUCCESS&quot; }
else              { $Status = &quot;UNKNOWN&quot; }

Write-Host &quot;Status: $Status&quot;

$Subject = &quot;[$Status] krbtgt rotation $Domain - $(Get-Date -Format &#x27;yyyy-MM-dd HH:mm&#x27;)&quot;
$Body    = @&quot;
krbtgt rotation - $Domain
Date    : $(Get-Date -Format &#x27;yyyy-MM-dd HH:mm:ss&#x27;)
Status  : $Status
DC      : pdc-infra.infradomaine.local
Log     : $($LogFile.FullName)

========== LOG ==========
$LogContent
&quot;@

try {
    Send-MailMessage -From $MailFrom -To $MailTo -Subject $Subject `
        -Body $Body -SmtpServer $SmtpServer -Port $SmtpPort -Encoding UTF8
    Write-Host &quot;Email sent to $MailTo&quot; -ForegroundColor Green
} catch {
    Write-Host &quot;Email ERROR: $_&quot; -ForegroundColor Red
}

exit $(if ($Failed) { 1 } else { 0 })

Third obstacle: the dual domain and the root forest

This is the most interesting issue in this implementation.

When the script runs on pdc-users.userdomain.local, it uses [Input] for the forest query. One might think it would choose userdomain.local as the current forest. But no—because of the trust relationship with subdomain.local, Windows presents subdomain.local as the root forest. The script therefore sees two domains in this forest, listed under numbers 1 and 2.

This behavior varies. In certain contexts (interactive session, direct connection), the script behaves correctly. From WinRM, the session is opened in a different context—I’ve encountered cases where the list of domains was empty (0 domains found), and others where it was correctly populated.

The stable solution: press [Enter] for the forest (which selects subdomain.local as the root forest, accessible via the trust relationship), then press [Enter] for the domain to select the machine’s current domain (userdomain.local).

# Response sequence for userdomain.local (from pdc-users):
# 1. &quot;Read info?&quot;          → no
# 2. &quot;Forest?&quot;             → [Enter] (foundation.local via trust)
# 3. &quot;Domain?&quot;             → [Enter] (userdomain.local = current domain)
# 4. &quot;Scope?&quot;              → 1
# 5. &quot;Continue mode 6?&quot;    → CONTINUE
# 6. &quot;Major impact?&quot;       → CONTINUE
$answersContent = &quot;no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n&quot;

Identical to the sequence for infradomain.local! The difference lies in the execution context: on pdc-infra, the current domain is infradomain.local; on pdc-users, it is userdomain.local.


Fourth obstacle: the Kerberos double-hop

With Kerberos transport over WinRM, the credentials from ansible_svc@USERDOMAINE.LOCAL are used for the initial connection to pdc-users. But Jorge’s script must then contact infradomaine.local to enumerate the forest—a second network authentication.

Kerberos does not allow this double-hop without explicit configuration. The script fails with:

The specified AD forest &#x27;infradomaine.local&#x27; IS NOT accessible!
Custom credentials are needed...
Script is running in automated mode and because of that it cannot ask for credentials...

The solution: use CredSSP (Credential Security Support Provider) for the WinRM connection to pdc-users. CredSSP delegates the Ansible user’s credentials to the DC, which can then use them for outbound network connections.

On the DC side (run once):

Enable-WSManCredSSP -Role Server -Force

On the Ansible side (srv-ansible):

pip install pywinrm[credssp] --break-system-packages

In the Ansible inventory for userdomain:

vars:
  ansible_user: &quot;ansible_svc@USERDOMAINE.LOCAL&quot;
  ansible_password: &quot;{{ vault_ansible_password_users }}&quot;
  ansible_connection: winrm
  ansible_winrm_transport: credssp
  ansible_winrm_server_cert_validation: ignore
  ansible_port: 5986
  ansible_winrm_scheme: https

> Security Note: CredSSP delegates credentials in plain text to the remote server. Use with a dedicated low-privilege account (only "Domain Admins" for krbtgt rotation), over encrypted connections (HTTPS/5986), and only on trusted DCs.


The Final Playbooks

The final structure is streamlined. No complex roles—just two playbooks, each calling the wrapper on the correct DC.

Structure on the Ansible Node

/etc/ansible/krbtgt-rotation/
├── inventory/
│   ├── infradomaine.yml
│   └── userdomaine.yml
├── site_infra.yml
├── site_users.yml
└── Invoke-KrbtgtRotation-Users.ps1   # reference

Infrastructure inventory.yml

all:
  children:
    infra_dcs:
      hosts:
        PDC-INFRA:
          ansible_host: pdc-infra.infradomaine.local
        DC-INFRA-02:
          ansible_host: dc-infra-02.infradomaine.local
      vars:
        ansible_user: &quot;ansible_svc@INFRADOMAINE.LOCAL&quot;
        ansible_password: &quot;{{ vault_ansible_password_infra }}&quot;
        ansible_connection: winrm
        ansible_winrm_transport: kerberos
        ansible_winrm_server_cert_validation: ignore
        ansible_port: 5986
        ansible_winrm_scheme: https

Playbook site_infra.yml

---
- name: &quot;krbtgt rotation - infradomaine.local&quot;
  hosts: PDC-INFRA
  gather_facts: false

  tasks:
    - name: &quot;Launch krbtgt rotation wrapper&quot;
      ansible.windows.win_powershell:
        script: |
          Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
          &amp; &quot;C:\Scripts\krbtgt\Invoke-KrbtgtRotation-Infra.ps1&quot;          
        executable: powershell.exe
      register: rotation_result

    - name: &quot;Result&quot;
      ansible.builtin.debug:
        msg: &quot;{{ rotation_result.output }}&quot;

Same structure for site_users.yml, targeting PDC-USERS.

Launch commands

cd /etc/ansible/krbtgt-rotation

# Subdomain rotation
ansible-playbook site_infra.yml \
  -i inventory/infradomaine.yml \
  -e &quot;@/etc/ansible/vault/secrets.yml&quot;

# User domain rotation
ansible-playbook site_users.yml \
  -i inventory/userdomaine.yml \
  -e &quot;@/etc/ansible/vault/secrets.yml&quot;

Automation with cron

Rotation is triggered daily. It is Jorge’s script—via the XML resetRoutineEnabled=TRUE and resetRoutineFirstResetIntervalInDays=90—that determines whether rotation is actually due or not. If the 90 days have not elapsed, the script returns SKIPPED and no changes are made.

# crontab -e (ansible user)

# krbtgt rotation for infradomaine.local - daily check at 2:00 AM
0 2 * * * cd /etc/ansible/krbtgt-rotation &amp;&amp; \
  ansible-playbook site_infra.yml \
  -i inventory/infradomaine.yml \
  -e &quot;@/etc/ansible/vault/secrets.yml&quot; \
  &gt;&gt; /var/log/ansible-krbtgt-infra.log 2&gt;&amp;1

# krbtgt rotation for userdomaine.local - daily check at 2:30 AM
30 2 * * * cd /etc/ansible/krbtgt-rotation &amp;&amp; \
  ansible-playbook site_users.yml \
  -i inventory/userdomaine.yml \
  -e &quot;@/etc/ansible/vault/secrets.yml&quot; \
  &gt;&gt; /var/log/ansible-krbtgt-users.log 2&gt;&amp;1

The 30-minute gap between the two prevents potential conflicts on shared resources (GAMMU, logs).


What the logs tell you

A successful rotation log looks like this:

Source Of Connection Parameters : XML Config File &#x27;C:\Scripts\krbtgt\New-KrbtgtKeys.xml&#x27; (Version: v0.4)
Use XML Config Settings         : TRUE
Reset Routine Enabled           : TRUE
Send Mail                       : TRUE

Total Number of KrbTGT Accounts Processed  : 1
Number of KrbTGT Accounts Candidate for Reset  : 1
Number of KrbTGT Accounts With SUCCESSFUL Reset: 1
No. of KrbTGT Accounts With FAILED Reset    : 0
No. of KrbTGT Accounts With SKIPPED Reset   : 0
No. of KrbTGT Accounts With ANOMALY DETECTED: 0

A log of a rotation not yet due (90-day schedule not yet elapsed):

No. of KrbTGT Account(s) With SKIPPED Reset   : 1

This is normal and expected behavior. The script checked the extensionAttribute2/3/4 on the krbtgt account, determined that rotation is not yet due, and terminated gracefully.

A non-blocking replication error (may occur occasionally):

Triggering Replicate Single Object On &#x27;dc-infra-02.infradomaine.local&#x27; Failed...
Exception Message: Exception while calling &quot;SetInfo&quot; with &quot;0&quot; argument(s)

* The (new) password for Object [CN=krbtgt,...] now exists in the AD database

The rotation succeeded despite the forced replication trigger error. The script then verifies that the new attribute is present on the secondary DC—and confirms that it is. Normal AD replication takes over.


Summary and Key Considerations

After several weeks of operation, here are my takeaways:

What works well:

  • Jorge’s script is robust and well-documented. Read the comments in the source code; they’re worth checking out
  • The PowerShell wrapper + Ansible combination is solid and easy to maintain
  • Tracking via extensionAttribute is elegant—the script knows when to run on its own
  • Email notifications arrive correctly, with the full log in the message body

What requires attention:

  • The stdin response sequence is fragile: if a question changes between two versions of the script, the entire sequence is thrown off. Check this after every script update
  • The XML file must be named exactly the same as the .ps1 script. This is a constraint that isn’t obviously documented
  • CredSSP over WinRM exposes credentials — reserve this transport for dedicated accounts on encrypted channels
  • Always test manually after modifying the wrapper, before letting the cron job run unattended

What I’ll do differently in the future:

  • Integrate an Ansible check that parses the returned log and explicitly fails if SUCCESSFUL Reset isn’t in the results
  • Add a notification to a ticketing system to track each rotation in the CMDB

In summary

Theory is good, practice is better. Between the streamlined playbook from Part 1 and what actually runs in production, there’s managing a stubborn interactive script, an XML file with silent naming constraints, a double-hop Kerberos to work around, and a root forest that isn’t always what you expect.

Nothing insurmountable—but you might as well save yourself those hours of debugging.

Krbtgt rotation is now scheduled, automated, notified via email, and traceable in Ansible logs. The most important account in your Active Directory is finally being treated with the seriousness it deserves.


A big thank you to Jorge de Almeida Pinto (Blog | GitHub) for his work on New-KrbtgtKeys.ps1. This is exactly the kind of contribution that makes the AD community stronger.