Check SSL expiry: openssl, curl, browser
Short answer
Run the OpenSSL command below, or paste the domain into a certificate checker. Either one shows the moment the certificate stops being valid.
In a browser, the same date is in the certificate viewer behind the icon to the left of the address bar. Scripts for many hosts, Java keystores, Node.js and Python are further down.
Checking by hand works until the week nobody checks. Expiry Watch checks your sites daily and emails you before a certificate expires. Free, with no account. Watch a domain
From a terminal, with your domain in place of example.com:
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, in a Java keystore, across many hosts and from code, and the traps that make a check report the wrong certificate. To hand a renewal or fix over to a host or client, see sharing a check in a ticket.
Unless it says otherwise, 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 checks one date: whether the certificate expires within the next N seconds (openssl-x509). It exits 1 if it does and 0 if it doesn’t; 30 days is 2,592,000 seconds. It checks nothing else: not notBefore, the chain, the hostname or revocation. Exit 0 means “not expiring within N seconds”, not “valid”.
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. If no certificate arrived, or date can’t read the date, it says the check failed instead of printing a number:
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
now=$(date +%s)
if [ -z "$end" ] || [[ ! $end_epoch =~ ^-?[0-9]+$ ]]; then
echo "$host: check failed (no certificate, or an unreadable date)"
elif [ "$end_epoch" -lt "$now" ]; then
echo "$host expired $end"
else
echo "$host expires $end ($(( (end_epoch - now) / 86400 )) days left)"
fi
example.com expires Dec 25 22:56:35 2026 GMT (72 days left)
A certificate already past its notAfter prints expired and the date, never a count of days.
With curl
These are bash commands. --verbose shows the certificate curl received as it connects. --head sends a HEAD request, so no page is downloaded, and without -L curl doesn’t follow redirects:
curl --verbose --head --silent --show-error --max-time 10 \
https://example.com 2>&1 \
| grep -E '^\* +(subject|start date|expire date|issuer):|^curl: '
* subject: CN=example.com
* start date: Sep 26 22:49:11 2026 GMT
* expire date: Dec 25 22:56:35 2026 GMT
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
These lines come from the TLS library curl was built with (here OpenSSL), so on other builds they look different or are missing: read them, don’t script them. A line starting curl: is curl’s error, such as curl: (60) for a certificate it doesn’t trust.
For a script or a ticket, --write-out '%{certs}' prints every certificate curl received, each as lines of details followed by the certificate between BEGIN CERTIFICATE and END CERTIFICATE markers. curl added it in 7.88.0, and the current manual lists the TLS backends that support it: OpenSSL, GnuTLS, Schannel and Rustls (--write-out, curl’s SSL certificates page). The manual promises only “the certificate chain with details”, not a format to parse, so save it to a file and let OpenSSL read the first certificate:
curl --head --silent --show-error --max-time 10 \
--output /dev/null --write-out '%{certs}' \
https://example.com > curl-certs.txt \
&& openssl x509 -in curl-certs.txt -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
Both outputs were captured on October 27, 2026, in bash on Linux, with curl 8.18.0 and OpenSSL 3.5.5.
- The first certificate is the site’s. The server must send its own certificate first (RFC 8446), and
openssl x509reads only the first one in the file. The others incurl-certs.txtare the certificate authorities’, with dates of their own: none of them is your certificate’s expiry. - A failure stops it.
--show-errorprints why curl failed,--max-time 10stops it after 10 seconds, and&&runs OpenSSL only when curl succeeded.>empties the file before curl starts, so an older capture can’t pass for this one, but after a failure the file may hold what curl received before it stopped, such as a chain it refused: don’t read dates from it. A failed check isn’t an expiry; to read a certificate curl refuses, use OpenSSL above. - If OpenSSL can’t load a certificate from the file, that’s a failed read, never an expiry. A curl older than 7.88.0, or built with a TLS backend that doesn’t support
certs, may print no certificates;curl --versionshows both. Use OpenSSL above instead. - This is one connection. By default curl verifies the certificate before it continues: its name, and that a CA it trusts signed it (
--insecure).openssl x509only reads the dates curl saved. Behind a CDN you see the CDN’s certificate, and through a proxy that inspects TLS, the proxy’s. An HTTP error or redirect in the answer doesn’t matter: curl doesn’t treat one as a failure (--fail), and the certificate came first.
In a Java keystore
Java applications often keep their certificate in a keystore file: PKCS12, the default since JDK 9, or the older JKS. keytool, which comes with the JDK, reads both (Oracle’s keytool manual). This lists every entry in the store, each with its dates:
keytool -list -v -keystore keystore.p12
Without -storepass, keytool asks for the store’s password, so the password never lands in your shell history; Oracle’s manual advises against putting it on the command line or in a script except for testing or on a secure system. Type it at the prompt. Pressing Enter instead leaves the store’s integrity unverified, which keytool warns about, and a PKCS12 store may then list fewer entries, or none.
To read one entry, add its alias (the Alias name in the full list; here, future):
keytool -list -v -keystore keystore.p12 -alias future
Valid from: Tue Oct 20 18:04:51 UTC 2026 until: Fri Nov 20 18:04:51 UTC 2026
Valid from is when the certificate starts being valid (notBefore), and until is when it expires (notAfter). keytool prints both in the local time zone of the machine it runs on, named in the line, where OpenSSL prints GMT. That line comes from a longer output for a throwaway test certificate, printed on October 21, 2026 by OpenJDK’s keytool 25.0.4.1 on Linux, with the time zone set to UTC.
The same commands read a JKS file (-keystore keystore.jks). For a certificate file, -printcert needs no keystore and no password, reads PEM or binary DER, and prints the same Valid from line:
keytool -printcert -file server.crt
These commands only read the file. Before you act on what they print:
- A date is not a validation. keytool shows what the certificate says. It doesn’t check the chain, the hostname or revocation.
- The store is not necessarily what’s live. A renewed keystore takes effect only once the server reloads it, so check what the server sends too, with OpenSSL above or our checker.
- Keep the keystore and its password where they are. A keystore can hold private keys. No website needs either to read an expiry date, and our SSL certificate checker never asks for a key, a keystore or a password: it reads only the certificate a site sends on port 443.
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, and flags anything with less than WARN_DAYS days left (30 by default). It compares seconds, not rounded days, so a certificate that expired an hour ago reads EXPIRED, not 0 days. A host it can’t reach, or a date it can’t read, is a failed check, never an expiry. macOS has no timeout command, so it falls back to gtimeout or Perl.
The output under it is from one run on October 21, 2026 at 18:21:34 UTC, on Linux with OpenSSL 3.5.5, against two local test servers with throwaway self-signed certificates, one valid and one already expired, and a name that doesn’t resolve. The test servers’ ports were random, so yours will differ, and a self-signed certificate’s dates say nothing about whether it can be trusted.
#!/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() { # prints nothing when date can't read the date
if date --version >/dev/null 2>&1; then date -u -d "$1" +%s 2>/dev/null
else date -u -j -f "%b %e %H:%M:%S %Y %Z" "$1" +%s 2>/dev/null; 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
end_epoch=$(to_epoch "$end")
if [[ ! $end_epoch =~ ^-?[0-9]+$ ]]; then
printf '%-24s %s\n' "$line" "check failed (can't read date: $end)"; continue
fi
left=$(( end_epoch - now )) # seconds, below 0 once expired
days=$(( left >= 0 ? left / 86400 : (left - 86399) / 86400 )) # whole days, rounded down
if [ "$left" -lt 0 ]; then flag="EXPIRED"
elif [ "$left" -lt $(( WARN_DAYS * 86400 )) ]; 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}"
127.0.0.1:33613 Dec 30 18:21:33 2026 GMT 69 days ok
127.0.0.1:46947 Oct 21 17:04:51 2026 GMT -1 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.
Sharing a check in a ticket or handoff
After a renewal or fix, a ticket should say what was observed, when, and how far it reaches. Our SSL certificate checker can copy that as plain text:
- Check the exact public hostname you changed, such as
www.example.com, not only the bare domain (it checks port 443). - Press Copy result under the result’s heading.
- Paste the text unchanged and in full. It already gives the time in UTC, what was and wasn’t checked, any failure, and the limits that apply. A “Check failed” or “Not checked” line is part of the observation: neither a pass nor an expiry.
A ticket can be this short:
[what changed or needs checking]
[paste the complete Copy result text here]
Does this match what you see from your side?
The result is what our checker saw from one place at that moment. A CDN can serve other certificates where our checker doesn’t reach, and an origin behind a CDN or on a private address isn’t checked at all. So it doesn’t show that every visitor or server has the new certificate.
The text ends with “Run this check again” and a link. Opening it runs a new check of that hostname: it isn’t a saved report, and may show something different. One check isn’t monitoring; to hear about later problems, check on a schedule.
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 on a server, schedule a GitHub workflow, or use a monitoring service.
On a GitHub schedule
If your code is on GitHub, a scheduled workflow can run the check without a server. Our check-expiry action needs no account with us: it checks each domain’s certificate and registration, and fails the run when one needs attention. Commit this as .github/workflows/expiry.yml on your default branch, the only branch scheduled workflows run on (GitHub Docs):
name: Expiry check
on:
schedule:
- cron: '17 6 * * *' # every day at 06:17 UTC
workflow_dispatch: # also lets you start a run by hand
permissions: {} # the action needs no access to your repository
jobs:
expiry:
runs-on: ubuntu-latest
steps:
- uses: hal-boring-utils/check-expiry-action@v1
with:
domains: |
example.com
www.example.com
The action runs one Python script with the runner’s own python3, so the job needs no checkout and no access to your repository: permissions: {} grants its token none. Before you rely on it:
- It checks from GitHub’s network. Each run connects from a GitHub-hosted runner, so a host that only answers inside your network shows as “could not check”, and behind a CDN you see the edge the runner reached.
- The warning follows the certificate’s lifetime. By default a certificate of 100 days or less warns once less than an eighth of its lifetime is left: 8 days for a 64-day certificate, about 11 for a 90-day one. Longer certificates, and registrations, warn at 30 days.
cert-daysanddomain-daysset fixed numbers instead. - A failed check is not an expiry. An expired or untrusted certificate fails the run, and so does a host it can’t reach, but the job summary says which: a host it can’t reach is “could not check”, never “expired”. Warnings fail the run too, unless you set
fail-on-warning: false. - Some registrations can’t be read. Registration dates come from the registry over RDAP. Some registries publish no expiry date, and a lookup that fails is reported without failing the run. The action’s README lists these and its other limits, and every option.
- Notification settings decide what reaches you. GitHub sends a scheduled workflow’s notifications to the person who created it until someone else changes its
cronline, and to whoever re-enables it after it was disabled; that person gets them only with Actions notifications turned on, on GitHub or by email (GitHub Docs). Even then an email can be filtered or missed, so the run history in the repository’s Actions tab is the record. - Schedules can run late or stop. GitHub may delay scheduled runs when Actions is busy, most often at the start of an hour (hence the odd minute above), and drop some queued jobs when the load is high enough. In a public repository it disables scheduled workflows after 60 days without repository activity, and the checks stop until you enable the workflow again (GitHub Docs).
With a monitoring service
If you’d rather get email alerts than keep a workflow, Expiry Watch is a separate, hosted option. It checks up to 10 domains every day for free from our own server, sends an alert when a certificate’s renewal looks late or an expiry is near, and reports a failed check as a failed check rather than an expiry. Its alerts follow their own timing, not the action’s rule, and go to an address you confirm. 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, GitHub’s documentation on October 20, 2026, Oracle’s keytool manual and OpenSSL 3.5’s openssl-x509 page on October 21, 2026, and curl’s pages and RFCs 8446 and 5246 on October 27, 2026.
- openssl-s_client and openssl-x509 manual pages, OpenSSL 3.6, and openssl-x509 for OpenSSL 3.5:
-checkendchecks whether the certificate expires within the given number of seconds. - keytool, Oracle’s Java SE 25 manual:
-listwith-vand-alias,-printcert -file(binary or RFC 1421 printable encoding, no keystore needed), the password prompt and its advice against passwords on the command line, and PKCS12 as the default keystore type since JDK 9. - curl manual:
--write-outvariables, curl project:certsoutputs “the certificate chain with details” and was added in 7.88.0; the current manual lists the OpenSSL, GnuTLS, Schannel and Rustls backends as supporting it. The same manual on--head,--location(redirects are followed only with it),--silent,--show-error,--max-time,--insecure(the check made without it) and--fail(HTTP response codes aren’t failures by default); and SSL certificates: thecertsoutput has BEGIN CERTIFICATE and END CERTIFICATE markers. - RFC 8446, section 4.4.2: the sender’s certificate must come first in TLS 1.3’s certificate list (RFC 5246 says the same for TLS 1.2).
- 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.
- Events that trigger workflows:
scheduleand Notifications for workflow runs, GitHub Docs: which branch schedules run on, delays and disabling, and who is notified how.