A clock crosses the certificate's valid-until line, and one by one the handshakes start failing.
Every TLS certificate carries a fixed validity window: a notBefore date and a notAfter date baked into the signed certificate itself. While the current time sits inside that window, clients trust the certificate and complete the handshake.
The moment wall-clock time passes notAfter, nothing about the server changes — but every conforming client now rejects the certificate as expired and aborts the connection with an error like CERTIFICATE_EXPIRED. The site stays up; the trust does not. The result is a silent, scheduled outage that arrives on a date you could have read months in advance.
A certificate's expiry is just two timestamps inside the signed structure. You don't have to wait to be surprised — read them directly:
# what is this cert's expiry date?
openssl x509 -enddate -noout -in cert.pem
# notAfter=Jun 25 23:59:59 2026 GMT
# how many days until it expires? (exits non-zero within 30 days)
openssl x509 -checkend $((30*24*3600)) -noout -in cert.pem \
&& echo "ok for 30+ days" || echo "expires within 30 days"
# check what a live endpoint actually serves (chain included)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
The durable fix is to never renew by hand. An ACME client such as certbot (or acme.sh, cert-manager) renews automatically as a cert nears expiry, typically running twice a day and acting around the one-third-of-lifetime mark:
# certbot renews anything inside its renewal window, then reloads the server
certbot renew --deploy-hook "systemctl reload nginx"
# independently, alert well before the wire: at T-30 and T-14 days
end=$(openssl x509 -enddate -noout -in cert.pem | cut -d= -f2)
days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
[ "$days_left" -le 14 ] && page "cert expires in $days_left days"
Two independent guardrails matter: auto-renewal that actually reloads the new cert into the serving process, and monitoring that pages on days-to-expiry regardless of whether renewal claims success.
| Signal | What it tells you |
|---|---|
| TLS handshake error rate spikes from near 0 to near 100% | Clients are reaching the server but refusing the certificate — a trust problem, not a capacity or 5xx problem. |
CERTIFICATE_EXPIRED / certificate has expired in client and SDK logs |
The rejection reason is named explicitly; the leaf or a chain cert is past notAfter. |
| Expiry monitor counts down to 0 days remaining | Renewal or reload did not happen and the wire has arrived. This should have paged days earlier. |
| Synthetic / uptime probes all fail at the same instant | A clean clock-crossing, not gradual degradation — the timestamp of first failure is the expiry second. |
Browsers show NET::ERR_CERT_DATE_INVALID; only clock-skewed clients still connect |
Confirms expiry: clients whose clock lags still see the cert as valid, which also hints at skew elsewhere. |
notAfter.At 00:00 UTC the on-call phone lights up. Dashboards show traffic still arriving, CPU flat, no 5xx — yet the TLS handshake error rate has jumped from near zero to roughly 100%, and synthetic probes from three regions all failed within the same second.
Detect. The logs are unambiguous: SSL routines::certificate has expired on the client side, NET::ERR_CERT_DATE_INVALID in the browser. A quick echo | openssl s_client -connect api.example.com:443 | openssl x509 -noout -dates shows notAfter=Jun 25 23:59:59 2026 GMT — the second the probes started failing. The clock has crossed expiry.
Contain. The priority is restoring trust, not assigning blame. The on-call forces a renewal — certbot renew --force-renewal — confirms a fresh cert with a notAfter a year out is on disk, then reloads it into the serving layer with systemctl reload nginx (and re-deploys the new cert to the load balancer and CDN). Handshakes recover the instant the new cert is live in memory; the error rate falls back toward zero.
Root cause. Auto-renewal had quietly been failing for weeks — the ACME HTTP-01 challenge broke when a routing rule changed, so certbot never obtained a new cert, and the expiry monitor's alert had been routed to a muted channel. Two gaps, one outage. The follow-ups: repair the renewal path, add a --deploy-hook reload so a renewed cert can never sit unused, and wire days-to-expiry alerts at T-30 and T-14 to a channel that actually pages.
Question 1. Handshakes are failing with CERTIFICATE_EXPIRED. Your renewal job's logs say it renewed the certificate successfully two weeks ago, and the file on disk shows a valid future notAfter. What is the most likely cause?
Question 2. The leaf certificate on disk is valid for another six months, yet browsers report the certificate as expired. What should you inspect next?