The quickest way to check a certificate’s expiry date from a terminal is OpenSSL:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate
notAfter=Dec 25 22:56:35 2026 GMT
notAfter is the moment the certificate stops being valid, in UTC. The rest of this guide covers the same check in a browser, with curl, across many hosts, and from code, plus the traps that make a check report the wrong certificate.
All output below was captured on October 14, 2026 with OpenSSL 3.6.4, curl 8.7.1, Node.js 24.20 and Python 3.14 on macOS. Your dates will differ, because certificates keep renewing.
In a browser
Browsers show the certificate they received, which is the quickest check when you’re already on the page.
- Firefox: click the shield icon to the left of the address bar, then Connection secure, then More site information, then View Certificate. The certificate opens in a tab; the Validity section shows the dates (Mozilla’s instructions).
- Chrome: click the View site information icon to the left of the address bar, then Connection is secure, then Certificate is valid. The certificate viewer lists the validity period. (Google’s help pages don’t list these labels; they come from Chromium’s interface strings.)
- Safari on Mac: Apple documents Safari > Connection Security Details for checking a site’s connection (Apple’s guide). For the exact expiry date, the OpenSSL command below is quicker.
Menu names change between browser versions; these are current as of October 2026. A browser check has two limits: it shows only the server you happened to reach, and browsers quietly repair some broken setups that other clients reject (see certificate chain errors).
With OpenSSL
openssl s_client connects and prints the certificates the server sends; openssl x509 reads one and prints the fields you ask for. Servers that host many sites choose the certificate by the name the client asks for (SNI). OpenSSL 1.1.1 and later send the -connect hostname automatically, but pass -servername anyway: you need it when you connect to an IP address, and the LibreSSL openssl that ships with macOS doesn’t send SNI without it.
Subject, issuer and both dates:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
subject=CN=example.com
issuer=C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
notBefore=Sep 26 22:49:11 2026 GMT
notAfter=Dec 25 22:56:35 2026 GMT
The names the certificate covers, to confirm it is the right one:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:example.com, DNS:*.example.com
On OpenSSL 3, -dateopt iso_8601 prints dates in a format that sorts and parses easily (notAfter=2026-12-25 22:56:35Z). The LibreSSL build that ships as /usr/bin/openssl on macOS rejects that option, and -ext too; on a Mac, install OpenSSL (for example with Homebrew), or list the names with openssl x509 -noout -text | grep -A1 'Subject Alternative Name'.
A yes-or-no check for scripts
-checkend N answers “will this certificate still be valid in N seconds?” with its exit code: 0 for yes, 1 for no. 30 days is 2,592,000 seconds.
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -checkend 2592000; echo "exit $?"
Certificate will not expire
exit 0
The same command against expired.badssl.com, a test site whose certificate expired in 2015, prints Certificate will expire and exits 1.
There is a trap: if the connection fails, openssl x509 gets no certificate, prints Could not find certificate from <stdin> and also exits 1. A script that only tests the exit code reports an unreachable host as an expiring certificate. Check that a certificate arrived before trusting the answer, as the scripts below do.
A certificate file on disk
openssl x509 -in cert.pem -noout -enddate
For a DER-encoded file (often .cer or .der), add -inform der. Remember that the file on disk is not necessarily what the server is sending: a renewed certificate only takes effect once the server reloads it.
Days left
To turn notAfter into days remaining, convert it to a Unix timestamp. GNU date (Linux) and BSD date (macOS) take different flags, so this snippet handles both:
host=example.com
end=$(echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
if date --version >/dev/null 2>&1; then
end_epoch=$(date -u -d "$end" +%s) # GNU date (Linux)
else
end_epoch=$(date -u -j -f "%b %e %H:%M:%S %Y %Z" "$end" +%s) # BSD date (macOS)
fi
echo "$host expires $end ($(( (end_epoch - $(date +%s)) / 86400 )) days left)"
example.com expires Dec 25 22:56:35 2026 GMT (72 days left)
With curl
curl -v prints the certificate it received while it makes a request:
curl -sIv https://example.com 2>&1 | grep -i 'expire date'
* expire date: Dec 25 22:56:35 2026 GMT
The verbose output depends on the TLS library curl was built with, so this line may look different or be missing on your system. With curl 7.88.0 or later and a supported TLS backend (OpenSSL, GnuTLS, Schannel or Rustls), --write-out '%{certs}' prints every certificate in the chain in a stable format:
curl -s -o /dev/null -w '%{certs}' https://example.com | grep -E '^(Subject|Expire date):'
Subject:CN = example.com
Expire date:Dec 25 22:56:35 2026 GMT
The output continues with the intermediate certificates, each with its own Expire date.
Many hosts at once
This script reads hosts from a file (one per line, host or host:port, # for comments), prints each expiry date and the days left, flags anything under WARN_DAYS (30 by default), and reports hosts it can’t reach as failed checks rather than expiries. macOS has no timeout command, so it falls back to gtimeout or Perl.
#!/usr/bin/env bash
# Usage: ./check-certs.sh hosts.txt (one host or host:port per line)
WARN_DAYS=${WARN_DAYS:-30}
TIMEOUT=10
run_with_timeout() {
local secs=$1; shift
if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@"
elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@"
else perl -e 'alarm shift; exec @ARGV' "$secs" "$@"
fi
}
to_epoch() {
if date --version >/dev/null 2>&1; then date -u -d "$1" +%s
else date -u -j -f "%b %e %H:%M:%S %Y %Z" "$1" +%s; fi
}
now=$(date +%s)
while IFS= read -r line || [ -n "$line" ]; do
line=${line%%#*}; line=${line//[[:space:]]/}
[ -z "$line" ] && continue
host=${line%%:*}; port=443
[ "$host" != "$line" ] && port=${line#*:}
end=$(run_with_timeout "$TIMEOUT" openssl s_client -connect "$host:$port" \
-servername "$host" </dev/null 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$end" ]; then printf '%-24s %s\n' "$line" "check failed"; continue; fi
days=$(( ($(to_epoch "$end") - now) / 86400 ))
if [ "$days" -lt 0 ]; then flag="EXPIRED"
elif [ "$days" -lt "$WARN_DAYS" ]; then flag="WARN (<${WARN_DAYS}d)"
else flag="ok"; fi
printf '%-24s %-26s %6s days %s\n' "$line" "$end" "$days" "$flag"
done < "${1:-hosts.txt}"
example.com Dec 25 22:56:35 2026 GMT 72 days ok
letsencrypt.org Dec 3 14:34:31 2026 GMT 49 days ok
github.com Nov 29 23:59:59 2026 GMT 46 days ok
expired.badssl.com Apr 12 23:59:59 2015 GMT -4202 days EXPIRED
no-such-host.invalid check failed
To get an email only when something needs attention, run it from cron with MAILTO set and pipe it through grep -v ' ok$'.
From Node.js
tls.connect with rejectUnauthorized: false lets you read a certificate even when it is expired or invalid; socket.authorized and socket.authorizationError then tell you whether it passed verification.
// cert-expiry.mjs Usage: node cert-expiry.mjs example.com [more hosts...]
import tls from 'node:tls';
function checkCert(host, port = 443, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const socket = tls.connect({ host, port, servername: host, rejectUnauthorized: false });
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`timeout after ${timeoutMs} ms`)));
socket.once('secureConnect', () => {
const cert = socket.getPeerCertificate();
resolve({
host,
validTo: cert.valid_to,
daysLeft: Math.floor((new Date(cert.valid_to) - Date.now()) / 86_400_000),
authorized: socket.authorized,
authorizationError: socket.authorizationError,
});
socket.end();
});
socket.once('error', reject);
});
}
for (const host of process.argv.slice(2)) {
try {
console.log(await checkCert(host));
} catch (err) {
console.log({ host, error: `${err.code ?? ''} ${err.message}`.trim() });
}
}
$ node cert-expiry.mjs example.com expired.badssl.com
{
host: 'example.com',
validTo: 'Dec 25 22:56:35 2026 GMT',
daysLeft: 72,
authorized: true,
authorizationError: null
}
{
host: 'expired.badssl.com',
validTo: 'Apr 12 23:59:59 2015 GMT',
daysLeft: -4203,
authorized: false,
authorizationError: 'CERT_HAS_EXPIRED'
}
Without rejectUnauthorized: false, the connection to the expired site fails with CERT_HAS_EXPIRED before you can read anything. On Node.js 22.10 or later, socket.getPeerX509Certificate().validToDate returns the expiry as a Date directly (X509Certificate docs).
From Python
The standard library is enough. ssl.create_default_context() verifies the chain and hostname, and ssl.cert_time_to_seconds parses the notAfter string.
# cert_expiry.py Usage: python3 cert_expiry.py example.com [more hosts...]
import socket
import ssl
import sys
import time
def days_left(host, port=443, timeout=10.0):
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
cert = tls.getpeercert()
not_after = cert["notAfter"]
return not_after, int((ssl.cert_time_to_seconds(not_after) - time.time()) // 86400)
for host in sys.argv[1:]:
try:
not_after, days = days_left(host)
print(f"{host}: expires {not_after} ({days} days left)")
except ssl.SSLCertVerificationError as e:
print(f"{host}: verification failed: {e.verify_message} (code {e.verify_code})")
except (ssl.SSLError, OSError) as e:
print(f"{host}: check failed: {e}")
$ python3 cert_expiry.py example.com expired.badssl.com
example.com: expires Dec 25 22:56:35 2026 GMT (72 days left)
expired.badssl.com: verification failed: certificate has expired (code 10)
Unlike the Node.js version, this one refuses to read a certificate that fails verification. Turning verification off doesn’t help: getpeercert() returns an empty dictionary when the certificate was not validated. To read the dates of an invalid certificate in Python, fetch it in binary form (getpeercert(binary_form=True)) and parse it with a library such as cryptography.
Make sure you’re checking the right certificate
Most “the check says it’s fine but users see an error” cases come down to checking a different certificate from the one users get:
- No SNI. When you connect to an IP address, or with a client that doesn’t send SNI (macOS’s LibreSSL
opensslwithout-servername, Node.js withoutservername), a shared server may return its default certificate or refuse the connection. - Several servers. Behind a load balancer or DNS round robin, each server can hold its own copy. Check each address:
openssl s_client -connect 203.0.113.10:443 -servername example.com. - A CDN in front. If a CDN terminates TLS, you’re seeing the CDN’s certificate. The origin’s certificate matters too, if the CDN validates it.
- Renewed but not reloaded. The new file is on disk; the running server still serves the old one. Only an external check catches this.
- Other ports. Mail (465, 993, 995), databases and admin panels often have their own certificates. Use
host:portin the script above. For services that upgrade to TLS on a plain connection (SMTP on 25 or 587), add-starttls smtptos_client.
Checking on a schedule
A check you run by hand tells you about today. To hear about it before an expiry, run the script above from cron, or use a monitoring service. Expiry Watch checks up to 3 domains a day for free, emails at 30, 14, 7, 3 and 1 day before expiry, and reports a failed check as a failed check rather than an expiry. For other options, see what to use now that Let’s Encrypt’s expiry emails have ended.
Sources
All sources were checked on October 14, 2026.
- openssl-s_client and openssl-x509 manual pages, OpenSSL 3.6.
- curl manual:
--write-outvariables, curl project:certswas added in 7.88.0 and is supported by the OpenSSL, GnuTLS, Schannel and Rustls backends. - Node.js
tlsmodule andX509Certificate(added in v15.6.0;validToDateadded in v22.10.0 and v23.0.0). - Python
sslmodule:cert_time_to_secondsandgetpeercert. - Secure website certificate, Mozilla Support.
- Avoid fraud by using encrypted websites in Safari on Mac, Apple Support.
- Page info strings, Chromium source: the labels of Chrome’s site information panel.
- badssl.com, test sites with deliberately broken certificates.