In partnership with

THE PROBLEM

Time is the dependency nobody thinks about until it takes down authentication.

Kerberos has a hard tolerance — five minutes by default. Drift past it and ticket validation fails. The error a user sees is not “your clock is wrong.” It’s a generic logon failure, a failed mapped drive, an application that can’t reach SQL, a scheduled task that stopped running. Four different teams start looking at four different systems and all of them are working correctly.

The drift itself is rarely dramatic. A VM’s clock wanders because the host was under load. Someone sets w32tm on a member server to point at an external NTP source, which seems reasonable and quietly breaks the domain hierarchy. A new DC gets promoted and takes its time from the wrong place. The PDC Emulator role moves during maintenance and nobody updates which server actually syncs externally.

The failure mode specific to virtualized DCs is worth calling out on its own: the hypervisor integration service helpfully syncs the guest clock to the host, the host syncs to something else entirely, and now your domain’s authoritative time source is a hypervisor that nobody configured for that job. This is a well-known misconfiguration and it persists in a lot of environments because everything works right up until it doesn’t.

This week we build the check.

THE SCRIPT

Save the script below to:

C:\Scripts\TimeSyncAudit.ps1

powershell

# Domain Time Synchronization Audit
# Automate & Operate — automateandoperate.com

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\TimeSyncAudit.log"
$reportFile = "C:\Logs\TimeSyncReport.csv"

# Machines to audit. Leave empty to auto-discover DCs plus enabled servers.
$computers = @()

# Set true to audit only domain controllers — faster, and DCs matter most
$domainControllersOnly = $false

# Drift thresholds in seconds.
# Kerberos default tolerance is 300s. Alert well before that.
$driftWarningSeconds = 30
$driftCriticalSeconds = 120

# External time sources the PDC Emulator is allowed to use.
# Anything else gets flagged.
$approvedExternalSources = @(
    "time.windows.com",
    "pool.ntp.org",
    "time.nist.gov",
    "0.pool.ntp.org",
    "1.pool.ntp.org"
)

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"

$report = @()
$critical = @()
$warnings = @()
$unreachable = @()
$pdcEmulator = $null
$domainName = "UNKNOWN"

# ---------- IDENTIFY THE PDC EMULATOR ----------
try {
    Import-Module ActiveDirectory -ErrorAction Stop
    $domain = Get-ADDomain -ErrorAction Stop
    $pdcEmulator = $domain.PDCEmulator
    $domainName = $domain.DNSRoot

    Add-Content -Path $logFile -Value "$timestamp — PDC Emulator: $pdcEmulator"
} catch {
    Add-Content -Path $logFile -Value "$timestamp — Could not identify PDC Emulator: $_"
}

# ---------- BUILD TARGET LIST ----------
if ($computers.Count -eq 0) {
    try {
        $dcs = (Get-ADDomainController -Filter * -ErrorAction Stop).HostName

        if ($domainControllersOnly) {
            $computers = $dcs
        } else {
            $members = (Get-ADComputer -Filter {
                Enabled -eq $true -and OperatingSystem -like "*Server*"
            } -Properties OperatingSystem).DNSHostName
            $computers = @($dcs) + @($members) | Where-Object { $_ } | Select-Object -Unique
        }

        Add-Content -Path $logFile -Value "$timestamp — Auditing $($computers.Count) machine(s)"
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — Discovery failed: $_ — using localhost"
        $computers = @("localhost")
    }
}

# ---------- REFERENCE TIME ----------
# Use the PDC Emulator as the reference if reachable, otherwise local clock.
$referenceTime = Get-Date
$referenceSource = "LOCAL ($env:COMPUTERNAME)"

if ($pdcEmulator) {
    try {
        $pdcTime = Invoke-Command -ComputerName $pdcEmulator -ScriptBlock {
            (Get-Date).ToUniversalTime()
        } -ErrorAction Stop

        $referenceTime = $pdcTime
        $referenceSource = "PDC EMULATOR ($pdcEmulator)"
        Add-Content -Path $logFile -Value "$timestamp — Reference time from PDC: $pdcTime UTC"
    } catch {
        $referenceTime = (Get-Date).ToUniversalTime()
        Add-Content -Path $logFile -Value "$timestamp — PDC unreachable for reference time, using local clock"
    }
} else {
    $referenceTime = (Get-Date).ToUniversalTime()
}

$referenceCapturedAt = Get-Date

foreach ($computer in $computers) {

    $shortName = ($computer -split '\.')[0]
    $isPDC = ($pdcEmulator -and ($computer -eq $pdcEmulator -or $shortName -eq ($pdcEmulator -split '\.')[0]))

    try {
        $timeInfo = Invoke-Command -ComputerName $computer -ScriptBlock {

            $localUtc = (Get-Date).ToUniversalTime()

            # w32tm configuration
            $w32tmConfig = w32tm /query /configuration 2>&1 | Out-String
            $w32tmStatus = w32tm /query /status 2>&1 | Out-String
            $w32tmSource = (w32tm /query /source 2>&1 | Out-String).Trim()

            # Parse type (NTP / NT5DS / AllSync / NoSync)
            $syncType = "UNKNOWN"
            if ($w32tmConfig -match "Type:\s*(\S+)") { $syncType = $matches[1] }

            # Parse configured NTP server list
            $ntpServer = "NOT SET"
            if ($w32tmConfig -match "NtpServer:\s*(.+?)(\r?\n)") { $ntpServer = $matches[1].Trim() }

            # Stratum
            $stratum = "UNKNOWN"
            if ($w32tmStatus -match "Stratum:\s*(\d+)") { $stratum = $matches[1] }

            # Last successful sync
            $lastSync = "UNKNOWN"
            if ($w32tmStatus -match "Last Successful Sync Time:\s*(.+?)(\r?\n)") {
                $lastSync = $matches[1].Trim()
            }

            # Service state
            $svc = Get-Service -Name W32Time -ErrorAction SilentlyContinue

            # Hypervisor time sync integration (the virtualized-DC trap)
            $hyperVTimeSync = "N/A"
            try {
                $vmicSvc = Get-Service -Name vmictimesync -ErrorAction SilentlyContinue
                if ($vmicSvc) {
                    $hyperVTimeSync = "$($vmicSvc.Status) / $($vmicSvc.StartType)"
                }
            } catch { }

            # Is this machine virtual?
            $isVirtual = $false
            try {
                $model = (Get-CimInstance Win32_ComputerSystem).Model
                $manufacturer = (Get-CimInstance Win32_ComputerSystem).Manufacturer
                if ($model -match "Virtual|VMware|KVM" -or $manufacturer -match "Microsoft Corporation|VMware|QEMU") {
                    $isVirtual = $true
                }
            } catch { }

            # Is it a DC?
            $isDC = $false
            try {
                $isDC = (Get-CimInstance Win32_ComputerSystem).DomainRole -in 4, 5
            } catch { }

            [PSCustomObject]@{
                LocalUtc       = $localUtc
                SyncType       = $syncType
                NtpServer      = $ntpServer
                ActualSource   = $w32tmSource
                Stratum        = $stratum
                LastSync       = $lastSync
                ServiceStatus  = if ($svc) { $svc.Status.ToString() } else { "NOT FOUND" }
                ServiceStart   = if ($svc) { $svc.StartType.ToString() } else { "N/A" }
                HyperVTimeSync = $hyperVTimeSync
                IsVirtual      = $isVirtual
                IsDC           = $isDC
            }

        } -ErrorAction Stop

        # ---- Calculate drift, compensating for round-trip elapsed ----
        $elapsed = (New-TimeSpan -Start $referenceCapturedAt -End (Get-Date)).TotalSeconds
        $expectedNow = $referenceTime.AddSeconds($elapsed)
        $driftSeconds = [math]::Round((New-TimeSpan -Start $expectedNow -End $timeInfo.LocalUtc).TotalSeconds, 1)
        $absDrift = [math]::Abs($driftSeconds)

        $flags = @()

        # ---- Drift ----
        if ($absDrift -ge $driftCriticalSeconds) {
            $flags += "DRIFT ${driftSeconds}s"
        } elseif ($absDrift -ge $driftWarningSeconds) {
            $flags += "DRIFT ${driftSeconds}s"
        }

        # ---- Service state ----
        if ($timeInfo.ServiceStatus -ne "Running") {
            $flags += "W32TIME $($timeInfo.ServiceStatus)"
        }
        if ($timeInfo.ServiceStart -eq "Disabled") {
            $flags += "W32TIME DISABLED"
        }

        # ---- Sync type correctness ----
        if ($isPDC) {
            # PDC should use NTP against an external source
            if ($timeInfo.SyncType -ne "NTP") {
                $flags += "PDC SHOULD BE TYPE NTP (is $($timeInfo.SyncType))"
            }

            $sourceApproved = $false
            foreach ($approved in $approvedExternalSources) {
                if ($timeInfo.NtpServer -like "*$approved*") { $sourceApproved = $true; break }
            }
            if (-not $sourceApproved) {
                $flags += "PDC NTP SOURCE NOT APPROVED: $($timeInfo.NtpServer)"
            }

            if ($timeInfo.ActualSource -match "Local CMOS Clock|Free-running") {
                $flags += "PDC IS SYNCING TO ITSELF — NO EXTERNAL TIME"
            }

        } else {
            # Domain members and non-PDC DCs should use NT5DS (domain hierarchy)
            if ($timeInfo.SyncType -eq "NTP") {
                $flags += "USING EXTERNAL NTP INSTEAD OF DOMAIN HIERARCHY"
            }
            if ($timeInfo.SyncType -eq "NoSync") {
                $flags += "TIME SYNC DISABLED (NoSync)"
            }
            if ($timeInfo.ActualSource -match "Local CMOS Clock|Free-running") {
                $flags += "NOT SYNCING — USING LOCAL CMOS CLOCK"
            }
        }

        # ---- Virtualized DC with host time sync enabled ----
        if ($timeInfo.IsDC -and $timeInfo.IsVirtual -and $timeInfo.HyperVTimeSync -like "Running*") {
            $flags += "VIRTUAL DC WITH HOST TIME SYNC ENABLED"
        }

        # ---- Stratum sanity ----
        if ($timeInfo.Stratum -ne "UNKNOWN" -and [int]$timeInfo.Stratum -ge 15) {
            $flags += "STRATUM $($timeInfo.Stratum) — UNSYNCHRONIZED"
        }

        $status = if ($flags -match "DRIFT" -and $absDrift -ge $driftCriticalSeconds) {
            "CRITICAL"
        } elseif ($flags -match "W32TIME DISABLED|NOT SYNCING|PDC IS SYNCING TO ITSELF|TIME SYNC DISABLED|UNSYNCHRONIZED|VIRTUAL DC WITH HOST") {
            "CRITICAL"
        } elseif ($flags.Count -gt 0) {
            "WARNING"
        } else {
            "OK"
        }

        $entry = [PSCustomObject]@{
            Date           = $dateStamp
            Computer       = $shortName
            IsPDC          = $isPDC
            IsDC           = $timeInfo.IsDC
            IsVirtual      = $timeInfo.IsVirtual
            DriftSeconds   = $driftSeconds
            SyncType       = $timeInfo.SyncType
            ConfiguredNTP  = $timeInfo.NtpServer
            ActualSource   = $timeInfo.ActualSource
            Stratum        = $timeInfo.Stratum
            LastSync       = $timeInfo.LastSync
            ServiceStatus  = $timeInfo.ServiceStatus
            HostTimeSync   = $timeInfo.HyperVTimeSync
            Status         = $status
            Flags          = if ($flags.Count -gt 0) { $flags -join "; " } else { "OK" }
        }

        $report += $entry
        if ($status -eq "CRITICAL") { $critical += $entry }
        if ($status -eq "WARNING")  { $warnings += $entry }

        Add-Content -Path $logFile -Value "$timestamp — $shortName — drift ${driftSeconds}s — type $($timeInfo.SyncType) — source $($timeInfo.ActualSource) — $status"

    } catch {
        $entry = [PSCustomObject]@{
            Date           = $dateStamp
            Computer       = $shortName
            IsPDC          = $isPDC
            IsDC           = "N/A"
            IsVirtual      = "N/A"
            DriftSeconds   = "N/A"
            SyncType       = "N/A"
            ConfiguredNTP  = "N/A"
            ActualSource   = "N/A"
            Stratum        = "N/A"
            LastSync       = "N/A"
            ServiceStatus  = "N/A"
            HostTimeSync   = "N/A"
            Status         = "ERROR"
            Flags          = "UNREACHABLE: $($_.Exception.Message)"
        }
        $report += $entry
        $unreachable += $entry
        Add-Content -Path $logFile -Value "$timestamp — $shortName — ERROR: $_"
    }
}

# ---------- EXPORT ----------
$report | Sort-Object Status, Computer | Export-Csv -Path $reportFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$maxDrift = ($report | Where-Object { $_.DriftSeconds -ne "N/A" } |
    Measure-Object -Property DriftSeconds -Maximum).Maximum
$pdcEntry = $report | Where-Object { $_.IsPDC -eq $true } | Select-Object -First 1

$emailBody  = "Domain Time Synchronization Audit — $timestamp`n"
$emailBody += "Domain: $domainName`n"
$emailBody += "PDC Emulator: $(if ($pdcEmulator) { $pdcEmulator } else { 'NOT IDENTIFIED' })`n"
$emailBody += "Reference clock: $referenceSource`n"
$emailBody += "Machines audited: $($computers.Count)  |  Unreachable: $($unreachable.Count)`n"
$emailBody += "Kerberos tolerance: 300s  |  Warning at ${driftWarningSeconds}s  |  Critical at ${driftCriticalSeconds}s`n`n"
$emailBody += "CRITICAL: $($critical.Count)  |  WARNING: $($warnings.Count)`n`n"

# PDC gets its own section — it's the root of the whole hierarchy
if ($pdcEntry) {
    $emailBody += "PDC EMULATOR STATUS (root of domain time hierarchy):`n"
    $emailBody += "=" * 60 + "`n"
    $emailBody += "Server:         $($pdcEntry.Computer)`n"
    $emailBody += "Sync type:      $($pdcEntry.SyncType)`n"
    $emailBody += "Configured NTP: $($pdcEntry.ConfiguredNTP)`n"
    $emailBody += "Actual source:  $($pdcEntry.ActualSource)`n"
    $emailBody += "Stratum:        $($pdcEntry.Stratum)`n"
    $emailBody += "Last sync:      $($pdcEntry.LastSync)`n"
    $emailBody += "Status:         $($pdcEntry.Status) — $($pdcEntry.Flags)`n`n"
}

if ($critical.Count -gt 0) {
    $emailBody += "*** CRITICAL ***`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($c in ($critical | Sort-Object { [math]::Abs($_.DriftSeconds) } -Descending)) {
        $emailBody += "Machine:      $($c.Computer)$(if ($c.IsDC -eq $true) { ' [DC]' })$(if ($c.IsPDC -eq $true) { ' [PDC]' })`n"
        $emailBody += "Drift:        $($c.DriftSeconds) seconds`n"
        $emailBody += "Sync type:    $($c.SyncType)`n"
        $emailBody += "Source:       $($c.ActualSource)`n"
        $emailBody += "Problem:      $($c.Flags)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($warnings.Count -gt 0) {
    $emailBody += "WARNINGS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($w in $warnings) {
        $emailBody += "$($w.Computer) | drift $($w.DriftSeconds)s | $($w.Flags)`n"
    }
    $emailBody += "`n"
}

if ($unreachable.Count -gt 0) {
    $emailBody += "UNREACHABLE:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($u in $unreachable) {
        $emailBody += "$($u.Computer) — $($u.Flags)`n"
    }
    $emailBody += "`n"
}

$emailBody += "ALL MACHINES (sorted by drift):`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in ($report | Where-Object { $_.DriftSeconds -ne "N/A" } |
                Sort-Object { [math]::Abs($_.DriftSeconds) } -Descending)) {
    $tag = ""
    if ($r.IsPDC -eq $true) { $tag = " [PDC]" } elseif ($r.IsDC -eq $true) { $tag = " [DC]" }
    $emailBody += "[$($r.Status)] $($r.Computer)$tag — $($r.DriftSeconds)s — $($r.SyncType) — $($r.ActualSource)`n"
}

if ($critical.Count -eq 0 -and $warnings.Count -eq 0) {
    $emailBody += "`nAll machines within tolerance and correctly configured.`n"
}

$emailBody += "`nFull report: $reportFile`n`n"
$emailBody += "Useful commands:`n"
$emailBody += "  w32tm /query /status            Current sync state`n"
$emailBody += "  w32tm /query /source            What it is actually syncing to`n"
$emailBody += "  w32tm /query /configuration     Full config`n"
$emailBody += "  w32tm /resync /force            Force an immediate resync`n"
$emailBody += "  w32tm /monitor                  Compare all DCs at once`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($critical.Count -gt 0) {
    "TIME SYNC CRITICAL: $($critical.Count) machine(s) — max drift ${maxDrift}s"
} elseif ($warnings.Count -gt 0) {
    "Time Sync Warning — $($warnings.Count) machine(s) drifting or misconfigured"
} else {
    "Time Sync Audit — All machines in sync"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $approvedExternalSources — add your own NTP sources if you use internal appliances

  • $domainControllersOnly — set $true for a fast DC-only check

Requirements: RSAT ActiveDirectory module, PowerShell remoting, admin rights on targets.

HOW DOMAIN TIME IS SUPPOSED TO WORK

Worth stating plainly, because the script flags deviations from it and the hierarchy is simpler than people assume.

The PDC Emulator syncs to an external NTP source. It’s the only machine in the domain that should. Type: NTP.

All other DCs sync from the PDC Emulator through the domain hierarchy. Type: NT5DS.

All domain members sync from their authenticating DC. Type: NT5DS.

That’s it. One external dependency, everything else flows down. The script checks each machine’s role against the type it’s actually configured with, which catches the two common breakages: a member server somebody pointed at time.nist.gov directly, and a PDC that was never given an external source and is quietly running off its own CMOS clock.

That second one is sneaky because the domain is perfectly internally consistent — every machine agrees with every other machine. They’re just all wrong together, drifting as a group, until something external like a cloud service or an MFA token rejects them.

THE VIRTUAL DC TRAP

The flag VIRTUAL DC WITH HOST TIME SYNC ENABLED deserves its own explanation.

On Hyper-V, the vmictimesync integration service syncs the guest clock to the host. Useful for most VMs. Actively harmful on a domain controller, because now the DC has two masters — the domain hierarchy and the hypervisor host — and they will disagree.

Microsoft’s guidance is to disable host time sync on DCs, or at minimum disable the periodic sync while leaving the boot-time sync alone. The common approach:

powershell

# On the Hyper-V host, for a DC guest
Get-VMIntegrationService -VMName "DC01" -Name "Time Synchronization" | Disable-VMIntegrationService

VMware has the equivalent in VMware Tools, and the same reasoning applies.

The script can only see the guest side — whether vmictimesync is running — so it flags the condition rather than claiming certainty. Verify on the host before changing anything.

FIXING THE COMMON CASES

PDC not using an external source:

powershell

w32tm /config /manualpeerlist:"time.windows.com,0x9 pool.ntp.org,0x9" /syncfromflags:manual /reliable:yes /update
Restart-Service w32time
w32tm /resync /force

The ,0x9 suffix sets the client to use the special poll interval — generally what you want for an external source.

A member server wrongly set to external NTP:

powershell

w32tm /config /syncfromflags:domhier /update
Restart-Service w32time
w32tm /resync /force

That returns it to the domain hierarchy.

Verify afterward:

powershell

w32tm /query /source
w32tm /query /status

Compare all DCs at once — this one is genuinely useful and most people don’t know it exists:

powershell

w32tm /monitor

It queries every DC in the domain and prints the offset of each relative to the local machine. Good for a quick sanity check before you start changing config.

A NOTE ON THE DRIFT NUMBERS

The script measures drift by capturing the PDC’s UTC time, then comparing each target’s UTC time against it, compensating for elapsed round-trip.

This is approximate. Network latency and remoting overhead introduce a second or two of noise, so treat anything under five seconds as measurement error rather than real drift. The warning threshold is deliberately set at 30 seconds to stay well clear of that.

For precise measurement use w32tm /stripchart /computer:TARGET /samples:5 /dataonly, which does a proper NTP exchange and reports actual offset. The script’s approach is fine for catching the machine that’s four minutes out; it’s not a metrology tool.

The thresholds matter more than the precision. Kerberos tolerates 300 seconds. Alerting at 30 gives you a long runway — a machine at 30 seconds of drift isn’t broken, but it’s trending somewhere, and knowing that before it hits 300 is the entire point.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click “Create Basic Task” → name it Time Sync Audit

  3. Trigger: Daily → 7:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\TimeSyncAudit.ps1"
  1. Click Next → Finish

Under Properties → General, check “Run with highest privileges”.

Daily is right. Drift accumulates gradually, and a daily reading catches a machine trending badly with days of margin before Kerberos starts rejecting it.

If you have a large fleet and the full run takes too long, set $domainControllersOnly = $true for the daily job and run the full fleet weekly. DCs are where the damage is concentrated — a member server with bad time affects one machine, a DC with bad time affects everything that authenticates against it.

TEST IT

Run as administrator:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\TimeSyncAudit.ps1"

Sanity-check locally first:

powershell

w32tm /query /source
w32tm /query /configuration
(Get-ADDomain).PDCEmulator

If w32tm /query /source on your PDC returns “Local CMOS Clock” or “Free-running System Clock,” you’ve found something before the script even ran.

To verify the drift detection works, nudge a clock on a non-production test VM by 90 seconds and run again — it should come back CRITICAL. Then:

powershell

w32tm /resync /force

WHY THIS MATTERS

Time is a dependency that nothing else declares. No service lists it, no monitoring tool checks it by default, and no error message mentions it. When it breaks, the symptoms appear in authentication, in logging, in scheduled jobs, in SIEM correlation, in certificate validation — everywhere except the clock.

It’s also one of the few failures that gets worse while you investigate, because drift keeps accumulating. A machine at 280 seconds when you start troubleshooting is at 310 by the time you’ve ruled out the network, and now the symptoms have changed.

The check is trivial. The outage it prevents is the kind that occupies four people for an afternoon.

THIS WEEK’S ACTION

Run it once against your DCs today. Look at the PDC EMULATOR section first — if the actual source says anything other than a real external NTP server, that’s your fix, and it’s a two-command job. Then check whether any DC shows host time sync running.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

The Dell Pro with built-in intelligence.

Built for business. Ready for anything.

With built-in intelligence, you can stay productive and focused on growing your small business. So no matter what the day throws at you, your Dell Pro can handle it.