TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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 DaysLeft shows 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 -AsTable for humans
  • .30day-cert-watch.ps1 -AsJson > certs-expiring.json for APIs/CI
  • .30day-cert-watch.ps1 | Export-Csv .\certs-expiring.csv -NoTypeInformation for 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\My for user-scoped client certs
  • Cert:\LocalMachine\Root if you also audit trusted roots (use with care)
  • Cert:\LocalMachine\CA for 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.exe for PowerShell 7, or powershell.exe for 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: NotAfter is 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 SilentlyContinue for noisy stores and wrap remote calls with try/catch to avoid failing the whole run.
  • Deterministic output: Always sort by DaysLeft then 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.

← All Posts Home →