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.ps1powershell
# 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 $credentialUpdate 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\CRLOverlapUnitsThe 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 -CRLThat 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 deleteTHE 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
Press Windows key → type Task Scheduler → open it
Click “Create Basic Task” → name it
PKI Health MonitorTrigger: Daily → 7:00am
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\PKIHealthMonitor.ps1"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.
