TLS Deep Dive
Three separate checks, three different errors
Certificate validation is not one operation. Knowing which of the three failed tells you where to look, and they have nothing to do with each other.
| Check | Fails when | Typical error |
|---|---|---|
| Validity | Now is outside notBefore / notAfter | certificate has expired (10) |
| Chain | No path to a trusted root | unable to get local issuer certificate (20) |
| Hostname | SNI is not in the SAN list | curl (60): no alternative name matches |
Note that the CN field has been ignored by browsers since 2017. Only the Subject Alternative Name list counts — a certificate with the right CN and a missing SAN entry fails, which surprises people migrating from very old tooling.
Why it works in Chrome and fails in curl
This is the missing-intermediate scenario, and it is worth understanding because it wastes so much time. Your server is supposed to send the leaf certificate and every intermediate above it. If it only sends the leaf, the client has a certificate signed by an authority it has never heard of.
Browsers paper over it in two ways: they cache intermediates seen on other sites, and most of them follow the AIA extension to download the missing certificate on the fly. curl, Go, Java and Python do neither. The result is a site that looks perfectly healthy in a browser tab while every server-to-server call fails — and the person testing in a browser cannot reproduce it.
# Wrong — leaf only
ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
# Right — leaf + intermediates
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
# Prove it: count the certificates the server actually sends
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"SNI: the reason one IP serves many sites
The server has to choose a certificate before it can say anything, but at that point it has not seen an HTTP request yet — the Host header is still encrypted, and in TLS 1.2 it does not exist yet at all. Server Name Indication solves this by putting the hostname in ClientHello, in the clear. That is what makes shared hosting and multi-tenant load balancers possible, and it is also the last unencrypted piece of metadata in a TLS 1.3 connection. Encrypted Client Hello is the extension closing that gap.
Commands that answer the question
# Full handshake and chain, with the correct SNI
openssl s_client -connect example.com:443 -servername example.com
# Just the expiry dates
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
# Which names does this certificate actually cover?
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
# Force a version to test legacy support
openssl s_client -connect example.com:443 -tls1_2
# Browser-equivalent verification, including the hostname check
curl -vI https://example.com

