Back to all posts
Tutorial
8 min read

How to Check SSL Certificate Expiration

DevToolLab Team

DevToolLab Team

October 5, 2026

How to Check SSL Certificate Expiration

To check when an SSL certificate expires, run openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -enddate. It prints the notAfter date of the certificate the server is serving right now. Swap -enddate for -checkend 2592000 to get a yes-or-no answer for the next 30 days that a script can act on.

Running that once is easy. Running it often enough is what changed: since March 15, 2026, no publicly trusted TLS certificate may be valid for more than 200 days, and Let's Encrypt stopped emailing expiry warnings on June 4, 2025. Every output below checks one real host, devtoollab.com, on October 5, 2026.

The Fastest Way

Type a hostname into the SSL Certificate Checker and it shows the expiry date, days remaining, issuer, Subject Alternative Names (SANs), TLS version and whether the chain is trusted. A web page cannot read a TLS handshake, so unlike most DevToolLab tools, this one connects from our server, not your browser. It only checks port 443; for a mail server on 465 or anything on a private network, run openssl from a machine that can reach it.

The DevToolLab SSL Certificate Checker showing devtoollab.com with a Valid badge, 88 days remaining, TLSv1.3, issuer Amazon RSA 2048 M04, valid to Dec 31, 2026, and SANs *.devtoollab.com and devtoollab.com
The DevToolLab SSL Certificate Checker showing devtoollab.com with a Valid badge, 88 days remaining, TLSv1.3, issuer Amazon RSA 2048 M04, valid to Dec 31, 2026, and SANs *.devtoollab.com and devtoollab.com

It reports 88 days; the commands below say 87 because the checker rounds partial days up.

Check a Live Server With openssl s_client

Bash
$ 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

</dev/null closes the input of s_client so it exits instead of waiting for you to type. -servername sends SNI (Server Name Indication), which tells a server hosting many sites which certificate to return. openssl x509 -noout parses the certificate without printing the Base64 blob. Use -enddate alone for just the expiry, or add -dateopt iso_8601 on OpenSSL 3.x for a date a script can parse: notAfter=2026-12-31 23:59:59Z.

June 17 to December 31 is about 198 days. devtoollab.com is served through AWS, and the AWS Certificate Manager (ACM) user guide says "ACM certificates are valid for 198 days," just inside the 200-day ceiling.

Get a Yes or No With -checkend

-checkend N asks whether the certificate expires within the next N seconds and sets the exit code: 0 means it will not, 1 means it will. Thirty days is 2,592,000 seconds.

Bash
$ 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

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

-checkend 0 means "has it already expired?" The second host is one of the deliberately broken test sites at badssl.com; its certificate expired April 12, 2015.

Check a Certificate File (PEM, CRT or PFX)

For a file on disk, point x509 at it. Any Base64 file with -----BEGIN CERTIFICATE----- inside works, whether it ends in .pem, .crt or .cer:

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

A .pfx or .p12 bundle, the format Windows and IIS export, packs the certificate with its private key behind a password. Extract the certificate first:

Bash
$ openssl pkcs12 -in devtoollab.pfx -nokeys -passin pass:changeit | openssl x509 -noout -enddate
notAfter=Dec 31 23:59:59 2026 GMT

If OpenSSL 3 refuses an older export with unsupported, the fix is in Common Errors below.

Check With curl

curl -v prints the certificate it validated while connecting, and -I sends a HEAD request so nothing downloads:

Bash
$ 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

Unlike openssl, curl verifies the chain and hostname by default, so exit code 0 means the certificate is valid now, not just that its end date is in the future. Against expired.badssl.com the same request exits 60. curl 8.7.1 (the macOS build) and 8.11.1 (Homebrew) printed identical lines.

Check SSL Certificate Expiration in Python

The standard library covers it. getpeercert() returns the verified certificate as a dict, and ssl.cert_time_to_seconds() converts its notAfter string:

Python
import socket
import ssl
import sys
from datetime import datetime, timezone

def cert_expiry(host: str, port: int = 443) -> datetime:
    ctx = ssl.create_default_context()
    with socket.create_connection((host, port), timeout=10) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as tls:
            not_after = tls.getpeercert()["notAfter"]
    return datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), tz=timezone.utc)

host = sys.argv[1] if len(sys.argv) > 1 else "devtoollab.com"
expires = cert_expiry(host)
days = (expires - datetime.now(timezone.utc)).days
print(f"{host} expires {expires:%Y-%m-%d %H:%M} UTC ({days} days left)")
text
$ python3 cert_expiry.py devtoollab.com
devtoollab.com expires 2026-12-31 23:59 UTC (87 days left)

create_default_context() verifies the certificate, so an expired one raises ssl.SSLCertVerificationError before you see the date, which in a monitor is the alert. Tested on Python 3.13.0.

Check SSL Certificate Expiration in Node.js

js
import tls from "node:tls";

const host = process.argv[2] ?? "devtoollab.com";

const socket = tls.connect({ host, port: 443, servername: host }, () => {
  const cert = socket.getPeerX509Certificate();
  const days = Math.floor((cert.validToDate - Date.now()) / 86_400_000);
  console.log(`${host} expires ${cert.validToDate.toISOString()} (${days} days left)`);
  socket.end();
});

socket.on("error", (err) => {
  console.error(`${host}: ${err.code} ${err.message}`);
  process.exitCode = 1;
});
text
$ node cert-expiry.mjs devtoollab.com
devtoollab.com expires 2026-12-31T23:59:59.000Z (87 days left)

getPeerX509Certificate() dates from Node.js 15.9.0, but validToDate needs Node.js 22.10.0 or 23.0.0 and later; on older versions, pass socket.getPeerCertificate().valid_to to new Date(). Node verifies by default, so an expired certificate lands in the error handler as CERT_HAS_EXPIRED. Tested on Node.js 25.5.0.

Check in Chrome or on Windows Server

In Chrome, click the icon at the left end of the address bar, then "Connection is secure", then "Certificate is valid" to open the certificate viewer with its validity dates. It shows only what your browser received, on your network, today.

On Windows Server, the certificates IIS uses live in the machine's My or WebHosting store, and PowerShell can filter them by expiration. This command is from Microsoft's about_Certificate_Provider documentation; we did not run it on Windows for this guide:

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

The store shows what is installed, not what a site serves, so check the live endpoint too.

Why Certificates Expire Faster Every Year

The CA/Browser Forum, where certificate authorities and browser makers set the rules for public certificates, passed Ballot SC-081 on April 11, 2025 with no votes against; Apple, Google, Microsoft and Mozilla all voted yes. Section 6.3.2 of the Baseline Requirements (version 2.3.1, effective October 4, 2026) steps the maximum lifetime down on a fixed schedule, and Let's Encrypt is shortening its own default on top of that.

DateWhat changesSource
June 4, 2025Let's Encrypt stops sending expiration notification emailsLet's Encrypt
March 15, 2026Maximum public TLS certificate lifetime drops from 398 to 200 daysCA/Browser Forum
February 10, 2027Let's Encrypt's default certificate drops from 90 to 64 daysLet's Encrypt
March 15, 2027Maximum lifetime drops to 100 daysCA/Browser Forum
February 16, 2028Let's Encrypt's default certificate drops to 45 daysLet's Encrypt
March 15, 2029Maximum lifetime drops to 47 daysCA/Browser Forum
Let's Encrypt blog post Decreasing Certificate Lifetimes to 45 Days, dated December 2, 2025, listing the May 13, 2026, February 10, 2027 and February 16, 2028 changes to its ACME profiles
Let's Encrypt blog post Decreasing Certificate Lifetimes to 45 Days, dated December 2, 2025, listing the May 13, 2026, February 10, 2027 and February 16, 2028 changes to its ACME profiles

A 47-day certificate gets replaced at least eight times a year. Nobody runs a command by hand that often, so the check belongs on a schedule.

Monitor Every Certificate on a Schedule

This script wraps -checkend for a list of hosts. It runs on Linux and on the bash 3.2 that ships with macOS:

Bash
#!/usr/bin/env bash
# Usage: DAYS=30 ./check-certs.sh host1 host2 ...
# Exit 1 if any certificate expires within $DAYS days, 2 if one cannot be fetched.
DAYS=${DAYS:-30}
status=0
for host in "$@"; do
  cert=$(openssl s_client -connect "$host:443" -servername "$host" </dev/null 2>/dev/null)
  if ! end=$(openssl x509 -noout -enddate <<<"$cert" 2>/dev/null); then
    echo "FAIL $host: no certificate returned"
    status=2
    continue
  fi
  if openssl x509 -noout -checkend $((DAYS * 86400)) <<<"$cert" >/dev/null; then
    echo "OK   $host ${end#notAfter=}"
  else
    echo "WARN $host ${end#notAfter=} (under $DAYS days)"
    [ "$status" -eq 0 ] && status=1
  fi
done
exit $status
text
$ ./check-certs.sh devtoollab.com www.devtoollab.com expired.badssl.com no-such-host.devtoollab.com
OK   devtoollab.com Dec 31 23:59:59 2026 GMT
OK   www.devtoollab.com Dec 31 23:59:59 2026 GMT
WARN expired.badssl.com Apr 12 23:59:59 2015 GMT (under 30 days)
FAIL no-such-host.devtoollab.com: no certificate returned
$ echo $?
2

Run it daily from cron (0 9 * * * /usr/local/bin/check-certs.sh devtoollab.com www.devtoollab.com) or as a scheduled CI job, where the non-zero exit fails the run and the failure notice is your alert.

Pick DAYS from your renewal setup. ACM tries to renew a public certificate 45 days before it expires, so devtoollab.com should get a new one around November 16, 2026. A 30-day threshold fires only if that renewal failed, and still leaves a month to fix it. With DAYS=90 it warns about devtoollab.com today, a certificate behaving normally, and a threshold above the renewal window trains you to ignore alerts. Let's Encrypt's guidance is to renew about two thirds of the way through a certificate's life, so alert a little below the last third. For a hosted alternative to cron, Let's Encrypt lists services on its monitoring options page.

Common Errors

sslv3 alert handshake failure ... SSL alert number 40 usually means no SNI was sent. The /usr/bin/openssl on macOS is LibreSSL 3.3.6, which only sends SNI when you pass -servername, and CloudFront rejects the handshake without it. OpenSSL 3.x sends it by default. Keep the flag either way.

verify error:num=10:certificate has expired is the openssl version; curl says curl: (60) SSL certificate problem: certificate has expired, Python certificate verify failed: certificate has expired, and Node.js CERT_HAS_EXPIRED. Renew it. If a renewed file already exists, the server never loaded it (see the next section).

verify error:num=20:unable to get local issuer certificate means the server sends its certificate without the intermediate that links it to a trusted root; Node.js calls it UNABLE_TO_VERIFY_LEAF_SIGNATURE. It often "works in the browser" because Firefox pre-downloads every trusted intermediate, a feature Mozilla announced November 13, 2020. In our test, macOS's built-in curl also loaded incomplete-chain.badssl.com, while Homebrew's curl, Python and Node.js rejected it. Serve the full chain (fullchain.pem with Certbot).

Hostname mismatch (Python), no alternative certificate subject name matches target host name (curl) and ERR_TLS_CERT_ALTNAME_INVALID (Node.js) mean the certificate does not list the name you connected to. A wildcard covers one label: *.badssl.com matches www.badssl.com but not wrong.host.badssl.com. openssl only checks the name if you add -verify_hostname <name>, which reports verify error:num=62:hostname mismatch.

error:0308010C:digital envelope routines::unsupported while reading a .pfx means the file was encrypted with RC2-40, which OpenSSL 3 disables by default. Add -legacy to the pkcs12 command, or open it in the PFX to PEM Converter.

When Not to Trust the Check

Check the endpoint, never the file. nginx reads certificate files when it loads its configuration (its docs note that only a path built from variables gets loaded per handshake), so a renewed file on disk changes nothing until nginx -s reload. With Certbot, put the reload in a --deploy-hook, which runs only after a successful renewal.

Behind a CDN, a public check only sees the edge certificate. The origin's can expire unnoticed until Cloudflare starts returning Error 526 in Full (strict) mode. To check it, point -connect at the origin's IP address and keep -servername set to the public name.

Never switch verification off in a monitor (curl -k, rejectUnauthorized: false) to read a date. It will report green while every real visitor gets an error page.

Conclusion

For a one-off check, the openssl s_client pipe from the top of this guide or the SSL Certificate Checker answers in seconds. Anything you will need to check again belongs in a script built on -checkend, run daily against the live endpoint, with the threshold set below your renewal window: 30 days for a 198-day ACM certificate like the one on devtoollab.com, which renews 45 days out. If a CDN sits in front, check the origin as well. With the maximum lifetime dropping to 100 days in March 2027 and 47 days in March 2029, plan for the renewal that fails quietly, not the one you remember to run.

  • SSL Certificate Checker - expiry, days left, issuer, SANs, TLS version and chain trust for any public site on port 443.
  • Certificate Decoder - read the validity dates, SANs and fingerprint of a PEM someone sent you, decoded in your browser.
  • OpenSSL Command Generator - the openssl commands around this check, from a new CSR to PEM to PFX, with every flag explained.
  • Cron Expression Generator - write the schedule for the daily check-certs.sh job so it runs at 9:00 AM, not 9:00 PM.

Related Posts

How to Open Large CSV Files (1M+ Rows)

Excel stops at 1,048,576 rows. Open a bigger CSV with DuckDB, pandas chunks, Polars or a browser viewer, or split it into Excel-sized files.

By DevToolLab Team•

How to Generate an SSH Key for GitHub

Run ssh-keygen -t ed25519, load the key into ssh-agent, paste the .pub file into GitHub, then test with ssh -T. Commands for Mac, Windows and Linux.

By DevToolLab Team•

How to Track AI Referral Traffic in GA4

GA4 now puts ChatGPT, Claude and Perplexity visits in an AI Assistant channel, but it missed 5.5% of our AI sessions. The custom channel and regex that fix it.

By DevToolLab Team•