The same error in every client
| Client | Error |
|---|---|
| Chrome, Edge | NET::ERR_CERT_DATE_INVALID |
| Firefox | SEC_ERROR_EXPIRED_CERTIFICATE (or ..._EXPIRED_ISSUER_CERTIFICATE) |
| curl, OpenSSL | SSL certificate problem: certificate has expired, verify error:num=10 |
| Python | CERTIFICATE_VERIFY_FAILED ... certificate has expired |
| Node.js | CERT_HAS_EXPIRED |
| Go | x509: certificate has expired or is not yet valid |
| Java | CertificateExpiredException |
None of them tells you which certificate in the chain failed. OpenSSL comes closest: openssl s_client prints depth=0 for the leaf, depth=1 and up for intermediates, next to the error.
Find out which certificate expired
Run the host through the SSL certificate chain checker. It lists every certificate the server sent with its dates, and reports the leaf and any expired intermediate as separate findings, so "leaf expired" and "intermediate expired" do not look the same. The SSL/TLS certificate checker gives the leaf's expiry date and days left, which is what you want to monitor.
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERT/,/END CERT/' | csplit -s -z -f cert- - '/BEGIN CERT/' '{*}'
for c in cert-*; do openssl x509 -in "$c" -noout -subject -enddate; done
The leaf expired
The usual case. Renewal is automated nearly everywhere now, so an expired leaf almost always means the automation broke quietly some weeks ago. Look for the reason in this order:
- It renewed, and nothing reloaded. certbot or acme.sh wrote a new file, but nginx, HAProxy or the Java app still has the old certificate in memory. The file on disk has a new date; the one being served does not. Add a deploy hook (
--deploy-hook "systemctl reload nginx") and reload now. - It renewed on one machine. Several servers, a load balancer or a CDN each hold a copy, and only the one running the ACME client got the new one. The error appears for some requests. Check each address, and ship certificates to every edge as part of the renewal.
- The challenge started failing. HTTP-01 needs port 80 reachable and
/.well-known/acme-challenge/not redirected somewhere that breaks it; DNS-01 needs API credentials that have not been rotated away. The ACME client's log says which. - A CAA record blocks the CA. Someone added CAA for a different CA, and the next renewal is refused. CAA records explained covers this one; it tends to surface months after the change.
- A manually bought certificate reached its end date. Nobody owned the calendar reminder.
Maximum certificate lifetimes are shrinking under the CA/Browser Forum's schedule, from 398 days to 200 in March 2026 and on down to 47 days by 2029. Manual renewal is no longer a realistic plan. If a certificate is still renewed by hand, automate it now or monitor it (below).
An intermediate expired, the leaf is fine
The leaf is new, the error persists, and the chain checker shows an old date on certificate two. The server's chain file was assembled years ago and nobody replaced the intermediate when the leaf was renewed. This is common with commercial CAs, where the leaf and the "bundle" arrive as separate downloads. Rebuild the chain from the intermediate that actually signed the current leaf; how to build a correct fullchain.pem has the steps.
A related case: an old root or cross-sign expired, and old clients fail even though a valid path exists. When Let's Encrypt's DST Root CA X3 cross-sign expired in September 2021, clients on OpenSSL 1.0.2 kept choosing the expired path and failed, while everything current chose the new root and worked. If only old clients fail, update the client or its trust store, or serve a chain that does not lead them to the expired certificate.
Nothing expired, and the client still says so
Check the clock on the client. A device whose date is years out (a Raspberry Pi with no real-time clock, a restored VM snapshot, a laptop with a dead CMOS battery) sees every certificate as expired or not yet valid. Go's message includes the time it believes it is, which gives it away. date and turning NTP on fix it.
Monitor it so it does not happen again
Alert on days left, not on the error. A daily job that fails under 14 days gives two weeks to fix a broken renewal:
days=$(curl -sf "https://kirkdiamond.com/tools/tls?host=example.com&format=json" | jq '.chain[0].days_left') [ "$days" -ge 14 ] || echo "example.com certificate expires in $days days"
More checks like this are in the API and curl reference. Domain Health also warns under 14 days and fails once the certificate has expired.