THE PROBLEM

Internal PKI is the infrastructure people set up once and then never think about again, which is unfortunate, because it has one of the nastiest failure modes in Windows.

Here’s the one that catches everyone: the CRL expires.

Your CA publishes a Certificate Revocation List with a validity window — often seven days, sometimes shorter. Clients check it to confirm a certificate hasn’t been revoked. When that list goes stale, clients don’t shrug and carry on. Depending on configuration, they treat revocation checking as failed and reject the certificate entirely.

The symptoms are spectacular and the cause is invisible. 802.1X authentication stops working for wireless clients. NPS rejects connections. Domain controllers refuse LDAPS. Internal sites throw certificate errors with no obvious reason. And every one of those systems is working perfectly — they’re correctly rejecting certificates whose revocation status cannot be confirmed.

Meanwhile the CA service is running, the server is up, monitoring is green, and nobody has thought about that box since it was built four years ago.

The second failure is slower but just as common: the CA’s own certificate expires. A root CA issued with a 10-year lifetime in 2017 is now a problem you have eighteen months to solve, and the renewal process is genuinely involved. You do not want to discover that deadline with six weeks left.

This week we build the monitor.

THE SCRIPT

Save the script below to:

C:\Scripts\PKIHealthMonitor.ps1

powershell

# Certificate Authority & PKI Health Monitor
# 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\PKIHealthMonitor.log"
$reportFile = "C:\Logs\PKIHealthReport.csv"

# CA servers to check. Leave empty to auto-discover from AD.
$caServers = @()

# CRL distribution points to fetch and validate.
# These should be the URLs your clients actually use — check a real
# issued cert with: certutil -url <path-to-cert>
$crlUrls = @(
    "http://pki.yourdomain.com/CertEnroll/YourCA.crl"
    # "http://pki.yourdomain.com/CertEnroll/YourCA+.crl"   # delta CRL
)

# Alert thresholds in hours / days
$crlWarningHours = 48
$crlCriticalHours = 24
$caCertWarningDays = 180
$caCertCriticalDays = 90
$issuedCertWarningDays = 30

# Check expiring certs in the local store on these machines
$certStoreServers = @("localhost")

# 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 = @()

# ---------- DISCOVER CAs FROM AD ----------
if ($caServers.Count -eq 0) {
    try {
        $configNC = (Get-ADRootDSE -ErrorAction Stop).configurationNamingContext
        $caPath = "CN=Enrollment Services,CN=Public Key Services,CN=Services,$configNC"

        $cas = Get-ADObject -SearchBase $caPath -Filter { objectClass -eq "pKIEnrollmentService" } `
            -Properties dNSHostName, cn -ErrorAction Stop

        $caServers = $cas | ForEach-Object {
            [PSCustomObject]@{ Name = $_.cn; Host = $_.dNSHostName }
        }

        Add-Content -Path $logFile -Value "$timestamp — Discovered $($caServers.Count) CA(s) from AD"

    } catch {
        Add-Content -Path $logFile -Value "$timestamp — CA discovery failed: $_"
        $caServers = @()
    }
} else {
    $caServers = $caServers | ForEach-Object {
        [PSCustomObject]@{ Name = $_; Host = $_ }
    }
}

# ---------- CHECK CA SERVICE AND CA CERTIFICATE ----------
foreach ($ca in $caServers) {

    $caHost = $ca.Host
    $caName = $ca.Name

    try {
        $caInfo = Invoke-Command -ComputerName $caHost -ScriptBlock {

            $svc = Get-Service -Name CertSvc -ErrorAction SilentlyContinue

            # CA certificate from the local store
            $caCerts = Get-ChildItem -Path Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
                Where-Object { $_.Subject -eq $_.Issuer -or $_.Extensions.Oid.FriendlyName -contains "Basic Constraints" } |
                Where-Object { $_.HasPrivateKey }

            $caCertInfo = $caCerts | ForEach-Object {
                [PSCustomObject]@{
                    Subject    = $_.Subject
                    NotAfter   = $_.NotAfter
                    Thumbprint = $_.Thumbprint
                }
            }

            # Pending requests sitting in the queue
            $pendingCount = $null
            try {
                $pendingRaw = certutil -view -restrict "Disposition=9" -out "RequestID" 2>&1 | Out-String
                if ($pendingRaw -match "Row (\d+)") { $pendingCount = $matches[1] }
            } catch { }

            [PSCustomObject]@{
                ServiceStatus = if ($svc) { $svc.Status.ToString() } else { "NOT INSTALLED" }
                ServiceStart  = if ($svc) { $svc.StartType.ToString() } else { "N/A" }
                CACerts       = $caCertInfo
                PendingRaw    = $pendingCount
            }

        } -ErrorAction Stop

        # ---- Service state ----
        $svcStatus = if ($caInfo.ServiceStatus -eq "Running") { "OK" } else { "CRITICAL" }

        $entry = [PSCustomObject]@{
            Date       = $dateStamp
            CheckType  = "CA Service"
            Target     = "$caName ($caHost)"
            Detail     = "CertSvc is $($caInfo.ServiceStatus), startup $($caInfo.ServiceStart)"
            DaysLeft   = "N/A"
            Status     = $svcStatus
        }
        $report += $entry
        if ($svcStatus -eq "CRITICAL") { $critical += $entry }

        Add-Content -Path $logFile -Value "$timestamp — $caHost — CertSvc: $($caInfo.ServiceStatus)"

        # ---- CA certificate expiry ----
        foreach ($cert in $caInfo.CACerts) {
            $daysLeft = (New-TimeSpan -Start (Get-Date) -End $cert.NotAfter).Days

            $certStatus = if ($daysLeft -le $caCertCriticalDays) {
                "CRITICAL"
            } elseif ($daysLeft -le $caCertWarningDays) {
                "WARNING"
            } else {
                "OK"
            }

            $entry = [PSCustomObject]@{
                Date       = $dateStamp
                CheckType  = "CA Certificate"
                Target     = "$caName — $($cert.Subject)"
                Detail     = "Expires $($cert.NotAfter.ToString('yyyy-MM-dd')) — thumbprint $($cert.Thumbprint)"
                DaysLeft   = $daysLeft
                Status     = $certStatus
            }
            $report += $entry
            if ($certStatus -eq "CRITICAL") { $critical += $entry }
            if ($certStatus -eq "WARNING")  { $warnings += $entry }

            Add-Content -Path $logFile -Value "$timestamp — $caHost — CA cert expires in $daysLeft days"
        }

    } catch {
        $entry = [PSCustomObject]@{
            Date       = $dateStamp
            CheckType  = "CA Service"
            Target     = "$caName ($caHost)"
            Detail     = "UNREACHABLE: $($_.Exception.Message)"
            DaysLeft   = "N/A"
            Status     = "CRITICAL"
        }
        $report += $entry
        $critical += $entry
        Add-Content -Path $logFile -Value "$timestamp — $caHost — ERROR: $_"
    }
}

# ---------- CHECK CRL FRESHNESS ----------
# This is the big one. Fetch each CRL the way a client would.
foreach ($url in $crlUrls) {

    $tempFile = Join-Path $env:TEMP "crlcheck_$([guid]::NewGuid().ToString('N')).crl"

    try {
        # Bypass any local cache — clients may cache, but we want ground truth
        Invoke-WebRequest -Uri $url -OutFile $tempFile -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop

        # Parse with certutil — reliable across Windows versions
        $crlDump = certutil -dump $tempFile 2>&1 | Out-String

        $thisUpdate = $null
        $nextUpdate = $null

        if ($crlDump -match "ThisUpdate:\s*(.+)") {
            $thisUpdate = [datetime]::Parse($matches[1].Trim())
        }
        if ($crlDump -match "NextUpdate:\s*(.+)") {
            $nextUpdate = [datetime]::Parse($matches[1].Trim())
        }

        if ($nextUpdate) {
            $hoursLeft = [math]::Round((New-TimeSpan -Start (Get-Date) -End $nextUpdate).TotalHours, 1)

            $crlStatus = if ($hoursLeft -le 0) {
                "CRITICAL"
            } elseif ($hoursLeft -le $crlCriticalHours) {
                "CRITICAL"
            } elseif ($hoursLeft -le $crlWarningHours) {
                "WARNING"
            } else {
                "OK"
            }

            $detailText = if ($hoursLeft -le 0) {
                "*** CRL EXPIRED $([math]::Abs($hoursLeft)) HOURS AGO *** Published $($thisUpdate.ToString('yyyy-MM-dd HH:mm')), expired $($nextUpdate.ToString('yyyy-MM-dd HH:mm'))"
            } else {
                "Published $($thisUpdate.ToString('yyyy-MM-dd HH:mm')), valid until $($nextUpdate.ToString('yyyy-MM-dd HH:mm'))"
            }

            $entry = [PSCustomObject]@{
                Date       = $dateStamp
                CheckType  = "CRL"
                Target     = $url
                Detail     = $detailText
                DaysLeft   = [math]::Round($hoursLeft / 24, 2)
                Status     = $crlStatus
            }
            $report += $entry
            if ($crlStatus -eq "CRITICAL") { $critical += $entry }
            if ($crlStatus -eq "WARNING")  { $warnings += $entry }

            Add-Content -Path $logFile -Value "$timestamp — CRL $url — valid for $hoursLeft more hours — $crlStatus"

        } else {
            $entry = [PSCustomObject]@{
                Date       = $dateStamp
                CheckType  = "CRL"
                Target     = $url
                Detail     = "DOWNLOADED BUT COULD NOT PARSE NextUpdate"
                DaysLeft   = "N/A"
                Status     = "CRITICAL"
            }
            $report += $entry
            $critical += $entry
            Add-Content -Path $logFile -Value "$timestamp — CRL $url — parse failure"
        }

    } catch {
        $entry = [PSCustomObject]@{
            Date       = $dateStamp
            CheckType  = "CRL"
            Target     = $url
            Detail     = "CRL UNREACHABLE: $($_.Exception.Message)"
            DaysLeft   = "N/A"
            Status     = "CRITICAL"
        }
        $report += $entry
        $critical += $entry
        Add-Content -Path $logFile -Value "$timestamp — CRL $url — UNREACHABLE: $_"

    } finally {
        if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue }
    }
}

# ---------- CHECK LOCAL MACHINE CERT STORES ----------
foreach ($server in $certStoreServers) {
    try {
        $certs = Invoke-Command -ComputerName $server -ArgumentList $issuedCertWarningDays -ScriptBlock {
            param($warnDays)

            Get-ChildItem -Path Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
                Where-Object {
                    $_.NotAfter -lt (Get-Date).AddDays($warnDays) -and $_.NotAfter -gt (Get-Date).AddDays(-30)
                } |
                ForEach-Object {
                    [PSCustomObject]@{
                        Subject    = $_.Subject
                        Issuer     = $_.Issuer
                        NotAfter   = $_.NotAfter
                        Thumbprint = $_.Thumbprint
                        HasKey     = $_.HasPrivateKey
                    }
                }

        } -ErrorAction Stop

        foreach ($cert in $certs) {
            $daysLeft = (New-TimeSpan -Start (Get-Date) -End $cert.NotAfter).Days

            $status = if ($daysLeft -le 0) {
                "CRITICAL"
            } elseif ($daysLeft -le 7) {
                "CRITICAL"
            } else {
                "WARNING"
            }

            $entry = [PSCustomObject]@{
                Date       = $dateStamp
                CheckType  = "Machine Certificate"
                Target     = "$server — $($cert.Subject)"
                Detail     = "Issued by $($cert.Issuer) — expires $($cert.NotAfter.ToString('yyyy-MM-dd'))"
                DaysLeft   = $daysLeft
                Status     = $status
            }
            $report += $entry
            if ($status -eq "CRITICAL") { $critical += $entry }
            if ($status -eq "WARNING")  { $warnings += $entry }
        }

        Add-Content -Path $logFile -Value "$timestamp — $server — $($certs.Count) cert(s) expiring within $issuedCertWarningDays days"

    } catch {
        Add-Content -Path $logFile -Value "$timestamp — $server — cert store check failed: $_"
    }
}

# ---------- EXPORT ----------
$report | Export-Csv -Path $reportFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$expiredCRLs = $report | Where-Object { $_.CheckType -eq "CRL" -and $_.Detail -like "*EXPIRED*" }

$emailBody  = "PKI Health Report — $timestamp`n"
$emailBody += "CAs checked: $($caServers.Count)`n"
$emailBody += "CRLs checked: $($crlUrls.Count)`n"
$emailBody += "CRITICAL: $($critical.Count)  |  WARNING: $($warnings.Count)`n`n"

if ($expiredCRLs.Count -gt 0) {
    $emailBody += "*** CRL EXPIRED — AUTHENTICATION MAY BE FAILING RIGHT NOW ***`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($e in $expiredCRLs) {
        $emailBody += "$($e.Target)`n"
        $emailBody += "$($e.Detail)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`nIMMEDIATE ACTION — republish the CRL on the CA:`n"
    $emailBody += "  certutil -CRL`n"
    $emailBody += "Then confirm it reached the distribution point.`n`n"
}

if ($critical.Count -gt 0) {
    $emailBody += "CRITICAL:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($c in $critical) {
        $emailBody += "[$($c.CheckType)] $($c.Target)`n"
        $emailBody += "   $($c.Detail)`n"
        if ($c.DaysLeft -ne "N/A") { $emailBody += "   Days left: $($c.DaysLeft)`n" }
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($warnings.Count -gt 0) {
    $emailBody += "WARNINGS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($w in $warnings) {
        $emailBody += "[$($w.CheckType)] $($w.Target) — $($w.DaysLeft) days — $($w.Detail)`n"
    }
    $emailBody += "`n"
}

$emailBody += "ALL CHECKS:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in ($report | Sort-Object CheckType)) {
    $emailBody += "[$($r.Status)] $($r.CheckType) — $($r.Target)`n"
}

if ($critical.Count -eq 0 -and $warnings.Count -eq 0) {
    $emailBody += "`nAll CA services running, CRLs current, no certificates expiring soon.`n"
}

$emailBody += "`nFull report: $reportFile`n`n"
$emailBody += "Useful commands:`n"
$emailBody += "  certutil -CRL                     Republish CRL on the CA`n"
$emailBody += "  certutil -getreg CA\CRLPeriod*    Check CRL publication interval`n"
$emailBody += "  certutil -url <cert file>         Validate a cert's CDP/AIA URLs`n"
$emailBody += "  pkiview.msc                       GUI PKI health view`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($expiredCRLs.Count -gt 0) {
    "*** PKI CRITICAL: CRL HAS EXPIRED — authentication at risk ***"
} elseif ($critical.Count -gt 0) {
    "PKI CRITICAL: $($critical.Count) issue(s) require attention"
} elseif ($warnings.Count -gt 0) {
    "PKI Warning — $($warnings.Count) item(s) approaching expiry"
} else {
    "PKI Health — All checks passed"
}

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)

  • $caServers — leave empty to auto-discover from AD

  • $crlUrls — this is the one that matters most, see below

  • $certStoreServers — add servers whose machine certs you want tracked

Requirements: RSAT ActiveDirectory module for CA discovery. PowerShell remoting and local admin on the CA servers. HTTP access to the CRL distribution points from wherever this runs.

GET THE CRL URLS RIGHT — EVERYTHING ELSE IS SECONDARY

The CRL check is the reason this script exists, and it only works if you’re checking the URLs your clients actually use.

Don’t guess. Pull a real certificate issued by your CA — a domain controller cert is a good choice — export it, and ask Windows directly:

powershell

certutil -url "C:\temp\dc-cert.cer"

That opens a dialog. Click CRLs then Retrieve. It shows every CDP URL embedded in the certificate and whether each one resolves. Those are the URLs to put in $crlUrls.

Or pull them from PowerShell:

powershell

$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*YourDC*" } | Select-Object -First 1
($cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq "CRL Distribution Points" }).Format($true)

Check from outside, not from the CA. The CA always has a current CRL locally. The question is whether clients can reach a current copy at the published location. Run this script from a member server or a DC — somewhere that has to go over the network like a real client does.

If you publish delta CRLs, add those URLs too. A stale delta causes the same failures as a stale base CRL.

WHY THE CRL EXPIRES IN THE FIRST PLACE

It’s almost never the CA forgetting to generate one. The CA publishes on schedule quite reliably. The failure is usually in the gap between generation and publication:

The CA was offline during the publication window. Common with offline root CAs — which is the whole point of an offline root, except somebody needs to manually publish that CRL before it lapses. Root CRLs often have long validity, which means the task comes up once a year and everyone has forgotten the procedure.

The publication target broke. The CA generates the CRL and copies it to a web server or file share. If that copy fails — permissions changed, share renamed, web server rebuilt — the CA has a fresh CRL that clients can’t reach.

The web server is serving a cached copy. IIS caching a .crl file is a real thing and it’s miserable to diagnose, because the CA shows success and the file on disk is current.

The CRL period is shorter than the publication interval. Misconfiguration, but it happens after someone tightens settings without checking the interaction.

Check your current settings:

powershell

certutil -getreg CA\CRLPeriod
certutil -getreg CA\CRLPeriodUnits
certutil -getreg CA\CRLOverlapPeriod
certutil -getreg CA\CRLOverlapUnits

The overlap period is your safety margin — how long the old CRL stays valid after the new one publishes. If it’s zero or very short, any hiccup in publication becomes an outage immediately.

WHEN IT HAS ALREADY EXPIRED

If the email says CRL EXPIRED, that’s not a planning item. Clients are likely failing authentication right now.

On the CA:

powershell

certutil -CRL

That republishes immediately. Then verify it actually landed — don’t assume:

powershell

Invoke-WebRequest -Uri "http://pki.yourdomain.com/CertEnroll/YourCA.crl" -OutFile "$env:TEMP\check.crl" -UseBasicParsing
certutil -dump "$env:TEMP\check.crl" | Select-String "NextUpdate"

If the NextUpdate hasn’t moved, the CRL generated fine but the copy to the distribution point failed. That’s your actual problem, and it’ll recur next cycle unless you fix the publication path.

Clients may also be caching the expired CRL. To clear on a specific machine:

powershell

certutil -urlcache CRL delete

THE CA CERTIFICATE COUNTDOWN

This is the slow-motion one, and it’s the reason to run this monthly even when everything is green.

A CA certificate expiring isn’t like a web cert expiring. You can’t just renew it on the day. Renewing a CA certificate means deciding whether to keep the existing key pair or generate a new one, re-publishing to AD, potentially re-issuing to subordinates, and dealing with the fact that certificates issued by the CA cannot outlive the CA’s own certificate.

That last point catches people. Once your CA cert has two years left, it will stop issuing two-year certificates — it silently truncates issued certificate lifetimes to match its own expiry. Clients start getting certs with odd short lifetimes and nobody understands why.

The script warns at 180 days and goes critical at 90. For a root CA renewal, 180 days is about right for comfort. If you discover yours has less than that, that’s this quarter’s project.

SETTING UP TASK SCHEDULER

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

  2. Click “Create Basic Task” → name it PKI Health Monitor

  3. Trigger: Daily → 7:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

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

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

Daily is the minimum for CRL checking. If your CRL period is short — some environments run 24 or 48 hours — consider every 6 hours. The window between “CRL expired” and “helpdesk phone rings” can be under an hour, and a daily check can miss most of a working day.

Run it from somewhere that isn’t the CA. A management server or a DC. The whole value of the CRL check is seeing what clients see.

TEST IT

Run it as administrator:

powershell

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

Before trusting the output, verify the CRL fetch works manually:

powershell

Invoke-WebRequest -Uri "http://pki.yourdomain.com/CertEnroll/YourCA.crl" -OutFile "$env:TEMP\test.crl" -UseBasicParsing
certutil -dump "$env:TEMP\test.crl"

You want to see ThisUpdate and NextUpdate with sensible dates. If the download 404s, your URL is wrong — go back to certutil -url on a real certificate.

For a broader view, pkiview.msc gives you Microsoft’s own PKI health snapshot including CDP and AIA status. Worth opening once to compare against what the script reports.

A NOTE ON SCOPE

This covers CRL freshness, CA service state, CA certificate expiry, and local machine certs. That’s the operational layer.

It does not cover certificate template permissions, which is where the ESC1-through-ESC8 escalation paths live. Misconfigured templates that let any authenticated user request a certificate with an arbitrary SAN are a genuine domain-takeover path, and they’re common in environments where PKI was set up years ago and never reviewed.

That’s a different tool and a different conversation — Certify and PSPKIAudit are the usual starting points. Worth knowing it exists as a separate problem from the one this script solves.

WHY THIS MATTERS

PKI failures are uniquely expensive to diagnose because the symptom and the cause look unrelated. Wireless stops working. VPN rejects users. LDAPS breaks. Four different teams start investigating four different systems, and all four systems are behaving correctly.

Meanwhile the actual cause is a file on a web server with a date in it that passed six hours ago.

A daily HTTP GET and a date comparison removes that entire category of incident. It’s the cheapest monitoring you’ll ever write relative to the outage it prevents, and almost nobody has it.

THIS WEEK’S ACTION

Pull a certificate your CA issued and run certutil -url on it to get the real CDP URLs. Put those in the script and run it once. Check the hours remaining on your CRL — if it’s under 48, you’ve just caught something before it caught you.

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

Automate & Operate — automateandoperate.com

Leave Granola and get up to 12 months free of Wispr Flow Notetaker + Dictation

If you have paid time left on an individual Granola plan, we'll match it with a Wispr Flow subscription that includes Notetaker and dictation, and add bonus time, up to 12 months total. Sign in or create a Wispr account and submit proof of your plan to check eligibility.