Monitor TLS Certificate Expiry with PowerShell: Spot Risks 30 Days Before Outage
Certificates dont usually fail at 2 a.m. but when they do, its almost always because an expiring TLS cert slipped through unnoticed. You can prevent those fire drills by continuously scanning your local certificate stores, calculating days left, and surfacing the riskiest entries first. In this post, youll build a PowerShell workflow that flags any certificate with less than 30 days remaining and presents output thats both human- and automation-friendly.
The core script: scan stores, compute days left, sort by risk
Start with a minimal script that inspects common local certificate stores, calculates remaining days until NotAfter, filters anything under 30 days, and sorts with expiring items first.
$stores = @('Cert:\LocalMachine\My','Cert:\LocalMachine\WebHosting')
$cutoff = (Get-Date).AddDays(30)
$items = foreach ($s in $stores) {
if (Test-Path $s) {
Get-ChildItem -Path $s -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -and ($_.NotAfter -lt $cutoff) } |
Select-Object @{N='Store';E={$s}}, Subject, Thumbprint, NotAfter,
@{N='DaysLeft';E={[int](($_.NotAfter - (Get-Date)).TotalDays)}}
}
}
if ($items) {
$items | Sort-Object DaysLeft | Format-Table -AutoSize
} else {
Write-Host 'All certificates are healthy (>= 30 days).'
}
How it works
- Stores: Scans Cert:\LocalMachine\My (personal) and Cert:\LocalMachine\WebHosting (commonly used by IIS).
- Cutoff: Everything expiring before 30 days gets flagged.
- DaysLeft: Computed using
NotAfter - (Get-Date); negative values indicate already expired. - Sorted by risk:
Sort-Object DaysLeftshows expiring/expired certs first.
Human vs. script-friendly output
For quick eyes-on reviews, Format-Table is ideal. For automation, return raw objects or export to JSON/CSV. A small tweak lets you support both.
param(
[int]$Days = 30,
[switch]$AsTable,
[switch]$AsJson,
[string[]]$StorePaths = @('Cert:\LocalMachine\My','Cert:\LocalMachine\WebHosting')
)
$cutoff = (Get-Date).AddDays($Days)
$report = foreach ($s in $StorePaths) {
if (Test-Path $s) {
Get-ChildItem -Path $s -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -and ($_.NotAfter -lt $cutoff) } |
Select-Object @{N='Store';E={$s}}, Subject, DnsNameList, Thumbprint, NotAfter,
@{N='DaysLeft';E={[int](($_.NotAfter - (Get-Date)).TotalDays)}}
}
}
$report = $report | Sort-Object DaysLeft
if ($AsJson) { $report | ConvertTo-Json -Depth 4 }
elseif ($AsTable) { $report | Format-Table -AutoSize }
else { $report }
Use it like:
. 30day-cert-watch.ps1 -AsTablefor humans. 30day-cert-watch.ps1 -AsJson > certs-expiring.jsonfor APIs/CI. 30day-cert-watch.ps1 | Export-Csv .\certs-expiring.csv -NoTypeInformationfor spreadsheets
Productionize it: parameters, alerts, scheduling, and fleet coverage
Make it reusable with a function
Wrap the logic in a function so you can import it into toolkits or reuse across scripts and CI pipelines.
function Get-CertExpiryReport {
[CmdletBinding()]
param(
[Parameter()] [string[]] $StorePaths = @(
'Cert:\LocalMachine\My',
'Cert:\LocalMachine\WebHosting'
),
[Parameter()] [int] $Days = 30
)
$cutoff = (Get-Date).AddDays($Days)
foreach ($s in $StorePaths) {
if (Test-Path $s) {
Get-ChildItem -Path $s -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -and ($_.NotAfter -lt $cutoff) } |
Select-Object @{N='Store';E={$s}}, Subject, DnsNameList, Thumbprint, NotAfter,
@{N='DaysLeft';E={[int](($_.NotAfter - (Get-Date)).TotalDays)}}
}
}
}
# Example usage
$report = Get-CertExpiryReport -Days 30
if ($report) {
$report | Sort-Object DaysLeft | Format-Table -AutoSize
} else {
Write-Host 'All certificates are healthy (>= 30 days).'
}
Extend StorePaths if needed:
Cert:\CurrentUser\Myfor user-scoped client certsCert:\LocalMachine\Rootif you also audit trusted roots (use with care)Cert:\LocalMachine\CAfor intermediate CAs
Automated alerts
Send actionable summaries to email or chat. For a quick SMTP mail (note: Send-MailMessage is deprecated; consider MailKit/Graph in production):
$report = Get-CertExpiryReport -Days 30 | Sort-Object DaysLeft
if ($report) {
$body = $report | Select-Object Store,Subject,Thumbprint,NotAfter,DaysLeft | Out-String
Send-MailMessage -SmtpServer 'smtp.example.com' -From 'certwatch@example.com' -To 'noc@example.com' \
-Subject 'TLS certificates expiring in < 30 days' -Body $body
}
For chat/webhooks, serialize to JSON:
$json = Get-CertExpiryReport -Days 30 | Sort-Object DaysLeft | ConvertTo-Json -Depth 4
Invoke-RestMethod -Method Post -Uri 'https://hooks.example.com/teams-or-slack' -Body $json -ContentType 'application/json'
Schedule it (Task Scheduler)
Run daily and alert early. Save your script as C:\Ops\cert-watch.ps1 and register a task:
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument '-NoProfile -File C:\Ops\cert-watch.ps1'
$trigger = New-ScheduledTaskTrigger -Daily -At 07:00
Register-ScheduledTask -TaskName 'TLS Cert Expiry Watch' -Action $action -Trigger $trigger -RunLevel Highest
Tips:
- Use
pwsh.exefor PowerShell 7, orpowershell.exefor Windows PowerShell 5.1. - Run under a service account with read access to the required certificate stores.
- Have the script write JSON/CSV to a known path for audits:
$report | Export-Csv C:\Ops\certs-expiring.csv -NoTypeInformation.
Exit codes for CI/CD
If your pipeline should fail on risky certs, return a non-zero exit code when anything is under the cutoff:
$report = Get-CertExpiryReport -Days 30 | Sort-Object DaysLeft
if ($report) { $report | Format-Table -AutoSize; exit 2 } else { Write-Host 'OK'; exit 0 }
Scan remote servers
Use PowerShell remoting to fan out across your fleet. This example checks LocalMachine\My across multiple servers and includes the computer name in results.
$servers = @('web01','api02','edge03')
Invoke-Command -ComputerName $servers -ScriptBlock {
param($days)
$cutoff = (Get-Date).AddDays($days)
Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -and ($_.NotAfter -lt $cutoff) } |
Select-Object @{N='ComputerName';E={$env:COMPUTERNAME}}, Subject, Thumbprint, NotAfter,
@{N='DaysLeft';E={[int](($_.NotAfter - (Get-Date)).TotalDays)}}
} -ArgumentList 30 |
Sort-Object DaysLeft, ComputerName
Feed this into your CMDB or SIEM by exporting to JSON/CSV.
Operational and security best practices
Harden the process
- Least privilege: Scanning certificate metadata typically doesnt require reading private keys. Run with the lowest rights that can enumerate the stores you need.
- Dont log secrets: Never attempt to export private keys in your reporting.
- Time zones:
NotAfteris a local DateTime in PowerShell. This is fine for days-left math, but if you aggregate across regions, normalize to UTC before storing centrally. - Noise control: If a cert is already replaced but old copies linger in the store, exclude revoked/unbound items or filter by Enhanced Key Usage (EKU) as needed.
- Threshold tiers: Consider two thresholds (e.g., warning at 30 days, critical at 7 days) and surface severity in your alerts.
Performance and reliability
- Timeouts and errors: Use
-ErrorAction SilentlyContinuefor noisy stores and wrap remote calls with try/catch to avoid failing the whole run. - Deterministic output: Always sort by
DaysLeftthen secondary keys (e.g., Subject) so diffs are stable in versioned reports. - Artifacts: Keep a rolling history (
YYYY-MM-DD.json) for audits and trend analysis. It also proves youve been monitoring. - Map to services: If you use IIS, match certificates to HTTPS bindings so owners know which site is at risk.
Import-Module WebAdministration
Get-WebBinding -Protocol https | ForEach-Object {
$hash = $_.certificateHash
if ($hash) {
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object Thumbprint -eq $hash
if ($cert) {
[pscustomobject]@{
Site = $_.ItemXPath
Binding = $_.bindingInformation
Subject = $cert.Subject
Thumbprint = $cert.Thumbprint
NotAfter = $cert.NotAfter
DaysLeft = [int](($cert.NotAfter - (Get-Date)).TotalDays)
}
}
}
} | Sort-Object DaysLeft | Format-Table -AutoSize
With a lightweight PowerShell routine and a daily schedule, youll catch expiring TLS certs long before they become outages. The pattern is simple: scan, calculate, sort, alert. Keep the output friendly for humans, but always return rich objects so you can automate renewals, enrich tickets, and integrate with CI/CD or chatops. Plan renewals, not fire drills.
Want more practical patterns like this? Explore the PowerShell Advanced Cookbook 30 scripting techniques, patterns, and production-ready recipes.