How-to guide · Operate and monitor
How to check when a certificate expires, and monitor it
In one sentence
You will have read expiry dates in files, live servers and certificate stores, and set up a check that warns before any certificate in a chain runs out.
- Platforms
- Linux
- macOS
- Windows
- Kubernetes
- Java
- Level
- Practitioner · 6 steps
On this page
Before you start
- OpenSSL (Linux, macOS with Homebrew, or Git Bash on Windows).
- GNU
datefor the script in step 5. Linux and Git Bash have it; on macOS, install GNU coreutils and usegdate. - A list of what to check: host names and ports, and where certificates are stored (files, Kubernetes secrets, Java keystores, Windows stores).
Expired certificates are among the most common self-inflicted outages, and the time between renewals keeps shrinking. Public TLS certificates may last at most 200 days from 15 March 2026, 100 days from 15 March 2027 and 47 days from 15 March 2029. And Let's Encrypt no longer sends expiration reminder emails (since 4 June 2025), so nobody else will warn you.
Steps
1. Read the end date of a certificate file
openssl x509 -in old.crt -noout -startdate -enddate
notBefore=Sep 1 00:00:00 2026 GMT
notAfter=Oct 24 00:00:00 2026 GMT
The times are in GMT. A certificate is valid from notBefore through notAfter, inclusive (RFC 5280, section 4.1.2.5). Both dates sit inside the signed part of the certificate, so nobody can extend them: renewing always means a new certificate.
2. Test a threshold with -checkend
-checkend takes a number of seconds and answers with an exit code: 0 if the certificate is still valid at that moment, 1 if it will have expired. The test certificate above, checked on 20 October 2026, seven days ahead and then one day ahead:
openssl x509 -in old.crt -noout -checkend 604800; echo "exit=$?"
openssl x509 -in old.crt -noout -checkend 86400; echo "exit=$?"
Certificate will expire
exit=1
Certificate will not expire
exit=0
Useful values: 86400 is one day, 604800 is seven days and 2592000 is 30 days.
3. Check what a server really sends
The file on disk is not always what clients get. Ask the live endpoint:
openssl s_client -connect www.rfc-editor.org:443 -servername www.rfc-editor.org </dev/null 2>/dev/null \
| openssl x509 -noout -subject -enddate
subject=CN=rfc-editor.org
notAfter=Jan 8 12:18:06 2027 GMT
Always pass -servername: a server that hosts many names may otherwise send its default certificate. curl built with OpenSSL (Linux, macOS with Homebrew) prints the same date in verbose mode:
curl -sv -o /dev/null https://www.rfc-editor.org/ 2>&1 | grep -E 'start date|expire date'
* start date: Oct 10 11:18:19 2026 GMT
* expire date: Jan 8 12:18:06 2027 GMT
The curl.exe that ships with Windows uses Schannel and does not print these lines.
4. Check certificates in stores
Kubernetes keeps TLS certificates in secrets of type kubernetes.io/tls:
kubectl get secrets -A --field-selector type=kubernetes.io/tls
kubectl get secret web-tls -n shop -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -enddate
subject=C=BE, O=Example Shop, CN=www.example.com
notAfter=Jan 18 01:42:24 2027 GMT
Java keystores, with the JDK’s keytool (the first line is the server certificate, the next its CA):
keytool -list -v -keystore www.p12 | grep -E 'Alias name|until:'
Alias name: www
Valid from: Tue Oct 20 03:42:24 CEST 2026 until: Mon Jan 18 02:42:24 CET 2027
Valid from: Tue Oct 20 03:42:23 CEST 2026 until: Sun Oct 19 03:42:23 CEST 2031
keytool shows local time, not GMT.
Windows certificate stores, in PowerShell:
# Still valid, but expiring within 30 days
Get-ChildItem -Path Cert:\LocalMachine\My -ExpiringInDays 30 | Select-Object Subject, NotAfter, Thumbprint
# Already expired
Get-ChildItem -Path Cert:\LocalMachine\My -ExpiringInDays 0 | Select-Object Subject, NotAfter, Thumbprint
-ExpiringInDays is a parameter of the certificate provider. With a positive number it returns only certificates that are still valid, so run both lines, or filter on NotAfter yourself.
5. Monitor the whole chain with a script
Checking only the server’s own certificate misses expired intermediates, which break every certificate below them. This script checks every certificate each server sends and warns when one has less than a sixth of its lifetime left:
#!/usr/bin/env bash
# check-expiry.sh: check every certificate each server sends.
# Warns when a certificate has less than 1/FRACTION of its lifetime left.
# Needs OpenSSL and GNU date (Linux, Git Bash). Usage: check-expiry.sh host[:port] ...
FRACTION=${FRACTION:-6}
status=0
check_one() { # $1 = target, $2 = position in chain, $3 = PEM certificate
local start end now subject
start=$(date -d "$(openssl x509 -noout -startdate <<< "$3" | cut -d= -f2)" +%s)
end=$(date -d "$(openssl x509 -noout -enddate <<< "$3" | cut -d= -f2)" +%s)
subject=$(openssl x509 -noout -subject -nameopt RFC2253 <<< "$3" | cut -d= -f2-)
now=$(date +%s)
local left=$(( (end - now) / 86400 )) life=$(( (end - start) / 86400 ))
if [ $(( (end - now) * FRACTION )) -lt $(( end - start )) ]; then
echo "WARN $1 #$2 $subject: $left of $life days left"; return 1
fi
echo "OK $1 #$2 $subject: $left of $life days left"
}
for target in "$@"; do
host=${target%%:*}; port=${target##*:}; [ "$port" = "$target" ] && port=443
chain=$(openssl s_client -connect "$host:$port" -servername "$host" -showcerts </dev/null 2>/dev/null \
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p')
if [ -z "$chain" ]; then echo "ERROR $target: no certificate received"; status=2; continue; fi
cert=""; i=0
while IFS= read -r line; do
cert+="$line"$'\n'
if [ "$line" = "-----END CERTIFICATE-----" ]; then
check_one "$target" "$i" "$cert" || { [ "$status" -eq 0 ] && status=1; }
cert=""; i=$((i + 1))
fi
done <<< "$chain"
done
exit "$status"
Run against two public sites and a test server whose certificate ends on 24 October:
bash check-expiry.sh www.rfc-editor.org letsencrypt.org 127.0.0.1:8445 nothing.invalid; echo "exit=$?"
OK www.rfc-editor.org #0 CN=rfc-editor.org: 80 of 90 days left
OK www.rfc-editor.org #1 CN=WE1,O=Google Trust Services,C=US: 854 of 1896 days left
OK www.rfc-editor.org #2 CN=GTS Root R4,O=Google Trust Services LLC,C=US: 464 of 1534 days left
OK letsencrypt.org #0 CN=letsencrypt.org: 44 of 89 days left
...
WARN 127.0.0.1:8445 #0 CN=old.example.com: 3 of 53 days left
OK 127.0.0.1:8445 #1 CN=Keystone Demo Issuing CA 1,O=Keystone Demo,C=BE: 1824 of 1825 days left
ERROR nothing.invalid: no certificate received
exit=2
The exit code is 0 when all is well, 1 for a warning and 2 when a server could not be reached. Most monitoring systems can run a command and alert on its exit code; a daily cron job also works:
0 7 * * * /usr/local/bin/check-expiry.sh www.example.com api.example.com:8443 || echo "certificate check failed" | mail -s "TLS expiry" ops@example.com
6. Choose when to alert
Alert on a share of the lifetime, not a fixed number of days. A rule such as “30 days before expiry” fires almost as soon as a 47 days certificate is issued. Let’s Encrypt’s integration guide recommends renewing “when they have a third of their total lifetime left” (halfway for certificates shorter than ten days), and ACME clients that support ARI, RFC 9773, on the IETF standards track, ask the CA when to renew. The script’s default of a sixth leaves time to fix a failed renewal before users notice. The calendar shows how often each lifetime must be renewed:
Check it worked
Prove the alert path, not just the script. FRACTION=1 makes every certificate “too close”, so the run must warn and exit with 1:
FRACTION=1 bash check-expiry.sh www.rfc-editor.org; echo "exit=$?"
WARN www.rfc-editor.org #0 CN=rfc-editor.org: 80 of 90 days left
...
exit=1
Wire that into your monitoring once and confirm the alert reaches the team that owns the certificate.
If something goes wrong
date: illegal option -- don macOS: BSDdatehas different options. Install GNU coreutils and replacedatewithgdate.- The server shows an old certificate after renewal: the service was not reloaded, or one node behind the load balancer still has the old file. Check each node by its address, with
-servernameset to the public name. - An intermediate is about to expire: install the chain that came with your latest certificate (How to build the full chain file). Clients report an expired link as
certificate has expired,CERT_HAS_EXPIRED, Java’sCertificateExpiredExceptionor Go’scertificate has expired or is not yet valid. - Users see
NET::ERR_CERT_DATE_INVALIDon a certificate that is in date: check the clock on their device. A wrong clock makes a good certificate look expired or not yet valid. ERROR … no certificate received: DNS, a firewall, or a port that does not speak TLS directly. Test it with How to check the certificate chain a server sends.
Why this works
The validity dates are part of the data the CA signs, and every client compares them with its own clock for every certificate in the path. One certificate out of date anywhere in the chain fails the whole connection. Reading notAfter from the live endpoint and from every store tells you exactly when that will happen. Inventory, expiry and renewal explains how to find every certificate and renew without an outage, Anatomy of an X.509 certificate shows where the dates sit, and ACME and the 47-day schedule explains how to automate renewal so that monitoring rarely has to fire.
Go deeper
- RFC 5280, section 4.1.2.5: the validity field and how dates are encoded.
- OpenSSL
x509manual:-enddate,-checkendand-nameopt. - Let’s Encrypt integration guide: when to renew, and ACME Renewal Information.
- Microsoft Learn: the Certificate provider:
-ExpiringInDaysand other filters. - Kubernetes: TLS secrets: how certificates are stored in a cluster.
Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.