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


