This error is a comparison, not a verdict about the certificate. Chrome takes the notBefore and notAfter fields from the certificate the server presented, compares them to the system clock, and refuses if today falls outside that window. Two variables feed that comparison — the certificate and the clock — and either can be the broken one. Firefox reports the same condition as SEC_ERROR_EXPIRED_CERTIFICATE, Safari as "this certificate has expired", and curl as certificate has expired (exit code 60).
How do I tell the three causes apart in under a minute?
Do not start with the browser. Start with a command-line client on a machine you trust, because it tells you what the server actually sent and what a correct clock thinks of it.
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 \
| grep -E "verify error|Verify return code|notAfter"
# Genuinely expired certificate:
# verify error:num=10:certificate has expired
# notAfter=Apr 12 23:59:59 2015 GMT
# Verify return code: 10 (certificate has expired)
# Healthy server (so the problem is the client):
# Verify return code: 0 (ok)Read the result like this:
- `notAfter` is in the past and verify code is 10 — the certificate really has expired. Renew it. Skip to the renewal section below.
- `notAfter` is comfortably in the future and verify code is 0 — the server is fine. The problem is on the complaining device: its clock, or the fact that it is reaching a different server than you just tested.
- `notAfter` is in the future but the subject is not the hostname you expected — you are being served the wrong certificate. That is an SNI or vhost problem, covered below.
Cause 1: the certificate genuinely expired
The boring case, and still the most common. Public TLS certificates are short-lived and getting shorter; automated renewal is the only sustainable answer. When automation is in place and the certificate expired anyway, the failure is almost always one of a small set:
- The renewal ran but the service was never reloaded. nginx and Apache hold certificates in memory. A new file on disk changes nothing until you reload. Check the on-disk date against the on-the-wire date — if they differ, this is your answer.
- The ACME HTTP-01 challenge broke. A redirect rule, an HSTS-forcing config, or a WAF that now blocks
/.well-known/acme-challenge/will fail renewal silently for weeks. Trace what a request to that path actually does with the Redirect Checker. - A CAA record blocks your CA. If someone tightened the zone, your CA is refused at issuance time. Check with the CAA Record Lookup.
- DNS-01 validation broke after a nameserver change. The renewal client is writing TXT records into a zone that is no longer authoritative. Confirm with the NS Record Lookup and TXT Record Lookup.
- The renewal timer is not running. systemd timer disabled, cron removed during a migration, or the container that held it was rebuilt.
Compare what is on disk with what is on the wire — the discrepancy is diagnostic:
# What the renewal client wrote:
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates
# What the server is actually presenting:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
# Different? Reload the service:
# nginx -t && systemctl reload nginx
# apachectl configtest && systemctl reload apache2Cause 2: the client clock is wrong
If the server checks out clean from your machine but one user still sees the error, their device thinks it is a different date. TLS validation has no way to know that, so it blames the certificate. The tell is that every HTTPS site fails, not just yours — ask the user to try two or three unrelated sites.
Classic sources of a wrong clock: a dead CMOS battery resetting a desktop to the BIOS epoch, a phone or laptop restored from a long-powered-off state, a virtual machine resumed from a snapshot, a container built without NTP, or a device whose timezone is right but whose date is a year out. A clock that is *ahead* produces the same error against a perfectly valid certificate — the browser thinks notAfter has already passed.
timedatectl shows whether NTP is synchronised.A related trap: a certificate that has not started yet. If notBefore is in the future — because it was just issued and the client's clock is behind, or because a CA backdates nothing while your VM is minutes slow — the browser shows the same date-invalid error even though nothing has expired. Reading both dates rather than just notAfter catches this immediately.
Cause 3: the server is presenting a different certificate
This is the cause that survives the longest, because everything looks correct when you test the wrong thing. If the client resolves the hostname to a different IP than you do, or connects without SNI, it gets a different certificate — often an old default vhost certificate that expired months ago and that nobody noticed because no one visits it deliberately.
# With SNI — what a modern browser sees
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -dates
# Without SNI — what the default vhost serves
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -subject -dates
# Pin the IP, keep the hostname — test one backend at a time
echo | openssl s_client -connect 203.0.113.10:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -datesIf the two differ, work through this list:
- Multiple A records or a stale one. One backend behind a round-robin is serving an old certificate. Enumerate them with the A Record Lookup and test each IP with the
-connect IP -servername hostform above. - The user resolves the name differently than you do. A stale cache, a split-horizon resolver, or a hosts-file entry sends them somewhere else entirely. Compare answers across resolvers with DNS Compare and worldwide with the propagation checker.
- A vhost is missing its certificate directive. Requests for a hostname with no matching
server_namefall through to the default block. Every hostname on the IP needs its own configuration. - TLS terminates at a CDN or load balancer. The certificate you renewed on the origin is not the one the public sees. Confirm where TLS terminates by checking response headers with the HTTP Headers Checker.
- The name is not on the certificate at all. A hostname missing from the SAN list produces
ERR_CERT_COMMON_NAME_INVALIDrather than a date error, but the two get reported interchangeably by users. Read the SAN list withopenssl x509 -noout -ext subjectAltName.
What if the dates are fine and it still fails?
Then the error you are looking at is probably not really a date error. Browsers surface several distinct failures through a similar interstitial, and the exact code matters:
| Chrome error | What it means | Where to look |
|---|---|---|
ERR_CERT_DATE_INVALID | Today is outside notBefore/notAfter per the client clock | Certificate dates, or the device clock |
ERR_CERT_AUTHORITY_INVALID | No path to a trusted root could be built | Incomplete chain |
ERR_CERT_COMMON_NAME_INVALID | Hostname is not in the certificate's SAN list | SAN list, wrong vhost |
ERR_SSL_PROTOCOL_ERROR | Handshake failed before certificate validation | TLS version, cipher config, or a middlebox |
NET::ERR_CERT_REVOKED | The certificate was revoked by the CA | Reissue; check for a key compromise |
One more possibility worth ruling out: TLS interception. Corporate proxies and endpoint security products reissue every certificate on the fly with their own CA. If a single user's browser shows an issuer you do not recognise and dates you never set, you are looking at their inspection appliance, not your server. Ask them for the issuer name shown in the certificate viewer.
Once the site is healthy again, set up an expiry alert so the next one never reaches a user — the scripts in Check SSL Expiry with OpenSSL take about ten minutes to put into cron.