Expired certs are one of those outages that are always avoidable and still happen anyway. Somebody set up a cert two years ago, the reminder went to a mailbox nobody reads, and now a site or an internal API is throwing errors at 8 AM.
You don't need a fancy tool to see when a cert expires. Here's how I check from the terminal with openssl on Linux/macOS and PowerShell on Windows, plus a couple of small scripts that check a list of hosts at once.
openssl: check a remote host
echo | openssl s_client -connect github.com:443 -servername github.com 2>/dev/null | openssl x509 -noout -enddate
Output:
notAfter=Jan 27 23:59:59 2027 GMT
A few notes:
- The
echo |closes the connection right away so s_client doesn't sit there waiting for input. -
-servernamesends SNI. Leave it off and on shared hosting or a CDN you can get the wrong cert back, which is a fun way to waste an afternoon. -
2>/dev/nullhides the handshake noise.
Add -subject or -issuer next to -enddate if you want to confirm you got the cert you expected.
openssl: "does it expire within N days?"
-checkend takes seconds and tells you if the cert will expire within that window. The exit code is the useful part: 0 means fine, 1 means it expires inside the window.
echo | openssl s_client -connect github.com:443 -servername github.com 2>/dev/null | openssl x509 -noout -checkend $((30*86400))
echo $?
Certificate will not expire
0
That exit code is what makes it easy to drop into cron or a CI job.
openssl: check a local .pem or .crt file
Same idea, just point it at the file:
openssl x509 -in cert.pem -noout -enddate
openssl x509 -in cert.pem -noout -checkend $((30*86400))
If the file is DER (binary) and not PEM, add -inform der.
A small bash loop for a list of hosts
Put your hosts in a text file, one per line. host:port works if it's not 443, and lines starting with # are skipped.
example.com
github.com
internal-app.example.local:8443
Then:
#!/usr/bin/env bash
# usage: ./check-certs.sh hosts.txt 30
HOSTS_FILE="${1:-hosts.txt}"
DAYS="${2:-30}"
SECS=$((DAYS * 86400))
while read -r host; do
[[ -z "$host" || "$host" == \#* ]] && continue
name="${host%%:*}"
port="${host##*:}"
[[ "$port" == "$host" ]] && port=443
cert=$(echo | timeout 10 openssl s_client -connect "$name:$port" -servername "$name" 2>/dev/null | openssl x509 2>/dev/null)
if [[ -z "$cert" ]]; then
echo "ERROR $host (could not get a cert)"
continue
fi
end=$(echo "$cert" | openssl x509 -noout -enddate | cut -d= -f2)
if echo "$cert" | openssl x509 -noout -checkend "$SECS" >/dev/null; then
echo "OK $host expires $end"
else
echo "WARN $host expires $end (within $DAYS days)"
fi
done < "$HOSTS_FILE"
Run it with a 60 day window:
$ ./check-certs.sh hosts.txt 60
WARN example.com expires Dec 25 22:56:35 2026 GMT (within 60 days)
OK github.com expires Jan 27 23:59:59 2027 GMT
WARN expired.badssl.com expires Apr 12 23:59:59 2015 GMT (within 60 days)
ERROR nope.invalid (could not get a cert)
Already expired certs show up as WARN too, since they're obviously inside the window. The timeout 10 keeps one dead host from hanging the whole run. If you want it to alert, pipe it through grep -E "WARN|ERROR" and send that wherever your alerts go.
PowerShell: check a remote host
There's no built in cmdlet for this, but .NET gives you everything you need. Open a TcpClient, wrap it in an SslStream, and read the cert off the handshake:
param(
[string[]]$Hosts = @('example.com','github.com'),
[int]$Days = 30
)
foreach ($h in $Hosts) {
$name, $port = $h -split ':'
if (-not $port) { $port = 443 }
try {
$tcp = [System.Net.Sockets.TcpClient]::new($name, [int]$port)
# the callback returns $true so we can still read expired or self-signed certs
$ssl = [System.Net.Security.SslStream]::new($tcp.GetStream(), $false, { $true })
$ssl.AuthenticateAsClient($name)
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ssl.RemoteCertificate)
$left = ($cert.NotAfter - (Get-Date)).Days
[pscustomobject]@{
Host = $h
NotAfter = $cert.NotAfter
DaysLeft = $left
Status = if ($left -le $Days) { 'WARN' } else { 'OK' }
}
} catch {
[pscustomobject]@{ Host = $h; NotAfter = $null; DaysLeft = $null; Status = 'ERROR' }
} finally {
if ($ssl) { $ssl.Dispose() }
if ($tcp) { $tcp.Dispose() }
$ssl = $null; $tcp = $null
}
}
Save it as Get-CertExpiry.ps1 and run:
PS> .\Get-CertExpiry.ps1 -Hosts example.com,github.com,expired.badssl.com -Days 60 | Format-Table
Host NotAfter DaysLeft Status
---- -------- -------- ------
example.com 12/25/2026 17:56:35 50 WARN
github.com 01/27/2027 18:59:59 83 OK
expired.badssl.com 04/12/2015 19:59:59 -4225 WARN
The { $true } validation callback matters. Without it, an expired or self-signed cert throws during the handshake and you never get to read the date, which is exactly the cert you care about. Since it's only reading the date and not sending anything, that's fine here. Don't copy that callback into code that actually sends data.
NotAfter comes back in local time, so it'll look a few hours off from the openssl GMT output. Same cert though.
PowerShell: certs in the local Windows store
For certs installed on the box itself (IIS, RDP, whatever got imported), the Cert: drive makes it a one liner:
Get-ChildItem Cert:\LocalMachine\My |
Where-Object { $_.NotAfter -lt (Get-Date).AddDays(30) } |
Select-Object Subject, Thumbprint, NotAfter |
Sort-Object NotAfter
Swap LocalMachine for CurrentUser to check your user store. And a local .cer/.crt file works too:
[System.Security.Cryptography.X509Certificates.X509Certificate2]::new("C:\certs\cert.pem").NotAfter
Wrapping up
None of this is complicated, the hard part is remembering to run it. Throw the bash script in cron or the PowerShell one in Task Scheduler, point it at a hosts file you actually keep up to date, and let it cook. Future you will be glad.
I'm building CertCadence to automate this kind of check; waitlist is at https://certcadence.pages.dev
Top comments (0)