Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

How to Check When an SSL Certificate Expires, From openssl to Cron

An expired certificate is one of the few outages that announces its own date months ahead, and it still takes sites down because nobody was looking at that date. The one-liner that reads it is short: openssl s_client

How to Check When an SSL Certificate Expires, From openssl to Cron

An expired certificate is one of the few outages that announces its own date months ahead, and it still takes sites down because nobody was looking at that date. The one-liner that reads it is short:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate

That prints notAfter= followed by the expiry of whatever certificate the server hands out right now. Replace -enddate with -checkend 2592000 and you get an exit code instead of a date, which is what a script needs.

What changed is how often you have to ask. Public TLS certificates have been capped at 200 days since March 15, 2026, and Let's Encrypt quit sending expiry reminder emails on June 4, 2025. This is the condensed version of a guide I wrote on DevToolLab; every output below comes from one real host, devtoollab.com, checked on October 5, 2026.

The no-terminal option

If you just want to look, the SSL Certificate Checker takes a hostname and reports the expiry date, days left, issuer, SANs, TLS version and a chain-trust verdict. JavaScript in a browser cannot see a TLS handshake, so it connects from a server, and only on port 443. Anything else (SMTP on 465, a private network) needs openssl from a machine with a route to it.

SSL Certificate Checker results for devtoollab.com: Valid, 88 days remaining, TLSv1.3, issued by Amazon RSA 2048 M04, valid until Dec 31, 2026

It said 88 days on the day I checked. The command-line tools below say 87, because the checker rounds a partial day up.

Reading the dates with openssl s_client

Ask for the subject, issuer and both dates in one pass:

$ openssl s_client -connect devtoollab.com:443 -servername devtoollab.com </dev/null 2>/dev/null \
    | openssl x509 -noout -subject -issuer -dates
subject=CN=*.devtoollab.com
issuer=C=US, O=Amazon, CN=Amazon RSA 2048 M04
notBefore=Jun 17 00:00:00 2026 GMT
notAfter=Dec 31 23:59:59 2026 GMT

The /dev/null redirect stops s_client from waiting on keyboard input, -servername sends SNI so a multi-site server returns the right certificate, and -noout suppresses the Base64 dump. On OpenSSL 3.x, -dateopt iso_8601 turns the date into a parseable 2026-12-31 23:59:59Z.

Count the days between those two dates and you land near 198. That is no accident: the site sits behind AWS, and AWS Certificate Manager issues certificates for 198 days, two under the new ceiling.

A pass/fail answer with -checkend

-checkend takes a number of seconds and tells you through the exit code whether the certificate dies inside that window. Exit 0 means it survives, exit 1 means it does not. Thirty days is 2,592,000 seconds:

$ openssl s_client -connect devtoollab.com:443 -servername devtoollab.com </dev/null 2>/dev/null \
    | openssl x509 -noout -checkend 2592000; echo "exit $?"
Certificate will not expire
exit 0

Pass 0 to ask whether it has already expired. Pointed at expired.badssl.com, one of the intentionally broken hosts on badssl.com whose certificate ran out on April 12, 2015, it prints Certificate will expire and exits 1.

Files on disk: PEM, CRT and PFX

Anything with a -----BEGIN CERTIFICATE----- block inside works with x509 -in, whatever the extension says (.pem, .crt, .cer):

$ openssl x509 -in devtoollab.pem -noout -enddate
notAfter=Dec 31 23:59:59 2026 GMT

A .pfx or .p12 file, which is what Windows and IIS export, bundles the certificate and private key behind a password. Pull the certificate out first and pipe it through:

openssl pkcs12 -in devtoollab.pfx -nokeys -passin pass:changeit | openssl x509 -noout -enddate

If OpenSSL 3 answers with unsupported, see the errors section.

curl does the verification for you

curl -v logs the certificate it accepted during the handshake, and -I keeps it to a HEAD request:

$ curl -svI https://devtoollab.com/ 2>&1 | grep -E 'start date|expire date|issuer'
*  start date: Jun 17 00:00:00 2026 GMT
*  expire date: Dec 31 23:59:59 2026 GMT
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M04

The difference from openssl is that curl checks the chain and the hostname by default. A zero exit from curl means the certificate is valid right now, not merely that its end date has not passed. The same command against expired.badssl.com exits with 60.

Python, standard library only

No third-party packages needed. This version takes any number of hosts:

import socket
import ssl
import sys
import time

def days_left(host: str, port: int = 443) -> int:
    ctx = ssl.create_default_context()
    with socket.create_connection((host, port), timeout=10) as raw:
        with ctx.wrap_socket(raw, server_hostname=host) as tls:
            cert = tls.getpeercert()
    expires = ssl.cert_time_to_seconds(cert["notAfter"])
    return int((expires - time.time()) // 86400)

for host in sys.argv[1:] or ["devtoollab.com"]:
    print(f"{host}: {days_left(host)} days left")
$ python3 days_left.py devtoollab.com www.devtoollab.com
devtoollab.com: 87 days left
www.devtoollab.com: 87 days left

Because create_default_context() verifies the certificate, an expired one never reaches the date math. It raises ssl.SSLCertVerificationError with certificate has expired, and in a monitoring job that exception is exactly the signal you want. I ran this on Python 3.13.0.

Node.js with node:tls

import tls from "node:tls";

function daysLeft(host) {
  return new Promise((resolve, reject) => {
    const socket = tls.connect({ host, port: 443, servername: host }, () => {
      const { validToDate } = socket.getPeerX509Certificate();
      socket.end();
      resolve(Math.floor((validToDate - Date.now()) / 86_400_000));
    });
    socket.once("error", reject);
  });
}

const host = process.argv[2] ?? "devtoollab.com";
console.log(`${host}: ${await daysLeft(host)} days left`);

On Node.js 25.5.0 this printed devtoollab.com: 87 days left. validToDate only exists from Node.js 22.10.0 (or 23.0.0) onward; on anything older, wrap socket.getPeerCertificate().valid_to in new Date(). An expired certificate rejects the promise with the code CERT_HAS_EXPIRED.

Browser and Windows Server

In Chrome, the site-information icon beside the URL leads to "Connection is secure" and then "Certificate is valid", which shows both dates for the certificate your browser received. On a Windows server, IIS certificates sit in the local machine's My or WebHosting store, and this PowerShell line from Microsoft's about_Certificate_Provider docs lists the ones expiring within 30 days (I have not run it on Windows myself):

Get-ChildItem -Path Cert:\LocalMachine\My -ExpiringInDays 30 | Select-Object Subject, NotAfter

That shows what is installed, not what the site serves, so check the endpoint too.

Lifetimes keep shrinking

In April 2025 the CA/Browser Forum, where certificate authorities and browser vendors set the rules for public certificates, adopted Ballot SC-081 unopposed, with Apple, Google, Microsoft and Mozilla voting yes. Let's Encrypt is also trimming its default lifetime, on its own timetable.

Date Change
June 4, 2025 Let's Encrypt ends expiry reminder emails
March 15, 2026 Public certificate maximum goes from 398 to 200 days
February 10, 2027 Let's Encrypt default goes from 90 to 64 days
March 15, 2027 Public maximum goes to 100 days
February 16, 2028 Let's Encrypt default goes to 45 days
March 15, 2029 Public maximum goes to 47 days

The Let's Encrypt blog post from December 2, 2025 announcing 45-day certificates and the dates its ACME profiles change

At 47 days you are swapping certificates eight or more times a year. Nobody remembers to check that often by hand, which is the real argument for automating it.

A cron-friendly check for many hosts

This wraps -checkend around a list of hostnames and runs on Linux as well as the bash 3.2 that macOS still ships:

#!/usr/bin/env bash
# DAYS=30 ./cert-watch.sh example.com api.example.com
# exit 0: all fine, 1: something expires within $DAYS days, 2: a host sent no certificate
DAYS=${DAYS:-30}
rc=0
for host in "$@"; do
  pem=$(openssl s_client -connect "$host:443" -servername "$host" </dev/null 2>/dev/null)
  if ! end=$(openssl x509 -noout -enddate <<<"$pem" 2>/dev/null); then
    echo "FAIL $host (no certificate)"
    rc=2
    continue
  fi
  end=${end#notAfter=}
  if openssl x509 -noout -checkend $((DAYS * 86400)) <<<"$pem" >/dev/null; then
    echo "OK   $host  $end"
  else
    echo "WARN $host  $end"
    [ "$rc" -eq 0 ] && rc=1
  fi
done
exit $rc
$ ./cert-watch.sh devtoollab.com expired.badssl.com no-such-host.devtoollab.com
OK   devtoollab.com  Dec 31 23:59:59 2026 GMT
WARN expired.badssl.com  Apr 12 23:59:59 2015 GMT
FAIL no-such-host.devtoollab.com (no certificate)
$ echo $?
2

Run it daily from cron or a CI job; the non-zero exit fails the run, and that failure is your alert.

The threshold is what people get wrong. ACM renews 45 days before expiry, so a 30-day alert fires only when that renewal has failed, with about a month left to fix it. At 90 it flags a healthy certificate today and everyone learns to ignore it. Let's Encrypt advises renewing about two thirds into a certificate's lifetime, so alert a little inside the last third. The full guide walks through picking that number, and Let's Encrypt keeps a list of hosted monitoring options if you would rather not run cron.

Errors you will hit

SSL alert number 40 (handshake failure). Usually no SNI went out. macOS ships LibreSSL 3.3.6 as /usr/bin/openssl, and it leaves SNI out unless -servername is on the command line; CloudFront refuses the handshake when SNI is missing. OpenSSL 3.x sends SNI by default, but keep the flag anyway.

certificate has expired. Same failure everywhere: verify error:num=10 in openssl, exit 60 in curl, CERT_HAS_EXPIRED in Node.js. If you already renewed, the server never loaded the new file.

unable to get local issuer certificate. The server leaves out the intermediate certificate between it and a trusted root (UNABLE_TO_VERIFY_LEAF_SIGNATURE in Node.js). Firefox hides this because it preloads trusted intermediates, a change Mozilla announced in November 2020, and macOS's built-in curl also loaded incomplete-chain.badssl.com in testing while Homebrew curl, Python and Node.js refused it. The fix is to serve the whole chain; with Certbot that means fullchain.pem.

Hostname mismatch. The certificate does not cover the name you connected to. Python reports Hostname mismatch and Node.js ERR_TLS_CERT_ALTNAME_INVALID. Wildcards match exactly one label, so *.badssl.com is fine for www.badssl.com and wrong for wrong.host.badssl.com. openssl ignores names entirely unless you pass -verify_hostname <name>.

error:0308010C:digital envelope routines::unsupported on a PFX. The file is encrypted with RC2-40, which OpenSSL 3 turns off by default. Either append -legacy to openssl pkcs12, or drop the file into the PFX to PEM Converter, which handles RC2, RC4 and 3DES files without extra flags.

When a green check is lying

Test the endpoint, not the file. nginx loads its certificate with the configuration, so a renewed file changes nothing until a reload. Certbot's --deploy-hook is the right home for that reload, since it fires only when a renewal went through.

A CDN means a public check only ever sees the edge certificate; the origin's can lapse until Cloudflare in Full (strict) mode returns Error 526. Point -connect at the origin IP and keep -servername on the public name.

And never disable verification (curl -k, rejectUnauthorized: false) just to get at the expiry. The dashboard says all is well while visitors stare at a browser warning.

The short version

A one-off look needs only the pipe at the top or the web checker. Anything recurring wants -checkend in a daily job against the live endpoint, with a threshold under the renewal window (30 days suits a 198-day ACM certificate renewed 45 days early). Add the origin if a CDN sits in front. With the cap at 100 days in 2027 and 47 in 2029, plan for the renewal that breaks quietly.

References

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.