DEV Community

CertCadence
CertCadence

Posted on

Checking SSL cert expiry from the command line (openssl and PowerShell)

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
Enter fullscreen mode Exit fullscreen mode

Output:

notAfter=Jan 27 23:59:59 2027 GMT
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • The echo | closes the connection right away so s_client doesn't sit there waiting for input.
  • -servername sends 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/null hides 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 $?
Enter fullscreen mode Exit fullscreen mode
Certificate will not expire
0
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)