TLS Certificate Failures: Incomplete Chains, SAN Mismatches and Handshakes That Die
A certificate that works in Chrome and fails in curl, Java or a Kubernetes pod is almost always a chain problem, not a certificate problem. This guide shows how to read a handshake precisely and identify which of the six common TLS faults you are looking at.
One command, read carefully
openssl s_client is the reference tool. The -servername flag sends SNI and is mandatory on any shared or virtual host — omit it and you will be served the default certificate and diagnose the wrong problem.
openssl s_client -connect www.example.com:443 -servername www.example.com < /dev/null
Four parts of the output carry the answer:
Certificate chain 0 s:CN=www.example.com i:C=US, O=Let's Encrypt, CN=R11 1 s:C=US, O=Let's Encrypt, CN=R11 i:C=US, O=Internet Security Research Group, CN=ISRG Root X1 SSL handshake has read 4283 bytes and written 398 bytes New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 Verify return code: 0 (ok)
- Chain — each entry's subject (
s:) must be matched by the next entry's issuer (i:). - Protocol and cipher — what was actually negotiated.
- Verify return code — the verdict.
The verify codes you will meet in practice:
| Code | Message | Cause |
|---|---|---|
| 0 | ok | Valid |
| 2 | unable to get issuer certificate | Issuer not present and not in the local store |
| 10 | certificate has expired | Expiry — check the clock too |
| 18 | self signed certificate | Self-signed leaf |
| 19 | self signed certificate in chain | Private CA not trusted by the client |
| 20 | unable to get local issuer certificate | Client has no path to a trusted root |
| 21 | unable to verify the first certificate | Server did not send its intermediate |
| 62 | hostname mismatch | Name not present in SAN |
The incomplete chain: works in browsers, fails everywhere else
Verify code 21 is the most frequently misdiagnosed TLS fault in production, because the symptom is inconsistent by design. Browsers cache intermediates they have seen elsewhere and some will fetch a missing one via the Authority Information Access extension. curl, Java, Go, Python and most language runtimes do neither — they fail outright.
The result is a site that works perfectly for every human who tests it and fails for every automated client.
# count what the server actually sends openssl s_client -connect www.example.com:443 -servername www.example.com \ < /dev/null 2>/dev/null | grep -c '^ *[0-9] s:'
A public certificate should normally send two: the leaf and at least one intermediate. Sending only the leaf is the bug. The root is deliberately not sent — clients must already trust it, and transmitting it wastes handshake bytes.
The fix is server-side: concatenate leaf then intermediates, in that order, never the other way round.
# correct order: leaf first, then intermediate(s) cat example.crt intermediate.crt > fullchain.crt
# nginx — ssl_certificate must be the full chain ssl_certificate /etc/ssl/certs/fullchain.crt; ssl_certificate_key /etc/ssl/private/example.key; # Apache 2.4.8+ — SSLCertificateFile takes the chain SSLCertificateFile /etc/ssl/certs/fullchain.crt SSLCertificateKeyFile /etc/ssl/private/example.key
Verify the order is right before reloading. An out-of-order chain is rejected by strict clients even though every certificate in it is valid:
openssl crl2pkcs7 -nocrl -certfile fullchain.crt \ | openssl pkcs7 -print_certs -noout
SAN, not CN
Common Name has been deprecated for hostname validation for years. Every current client validates against the Subject Alternative Name extension exclusively, and a certificate with the hostname only in CN will be rejected even though the name is plainly visible.
openssl s_client -connect www.example.com:443 -servername www.example.com \ < /dev/null 2>/dev/null \ | openssl x509 -noout -subject -ext subjectAltName # subject=CN=www.example.com # X509v3 Subject Alternative Name: # DNS:www.example.com, DNS:example.com
Two wildcard rules catch people out:
*.example.commatcheswww.example.combut notexample.comitself — the apex needs its own SAN entry.- Wildcards match exactly one label.
*.example.comdoes not matcha.b.example.com.
SNI and the wrong certificate
When several hostnames share an address, the client must declare which it wants during the handshake. A client that omits SNI receives the server's default certificate, which triggers a hostname mismatch that looks like a certificate problem but is a client problem.
# with SNI — correct certificate openssl s_client -connect 203.0.113.10:443 -servername shop.example.com < /dev/null \ 2>/dev/null | openssl x509 -noout -subject # without SNI — default certificate openssl s_client -connect 203.0.113.10:443 < /dev/null \ 2>/dev/null | openssl x509 -noout -subject
If those return different subjects, the server is SNI-dependent. Old Java 6/7 clients, some embedded devices and a few monitoring agents still omit SNI; for those, the only reliable fix is a dedicated address or a certificate that covers the default vhost too.
Expiry, clock skew and the renewal that did not reload
Check both ends of the validity window. A certificate that is not yet valid produces a near-identical error to one that has expired, and on a host with bad time both can happen at once:
openssl s_client -connect www.example.com:443 -servername www.example.com \ < /dev/null 2>/dev/null | openssl x509 -noout -dates # notBefore=Sep 1 00:00:00 2026 GMT # notAfter=Nov 30 23:59:59 2026 GMT timedatectl status # verify NTP sync on both client and server
A very common operational failure is renewal succeeding on disk while the running process continues serving the old certificate from memory. Compare the file against what is actually being served:
# what is on disk openssl x509 -in /etc/ssl/certs/fullchain.crt -noout -enddate # what is being served right now openssl s_client -connect localhost:443 -servername www.example.com \ < /dev/null 2>/dev/null | openssl x509 -noout -enddate
If they differ, the renewal hook never reloaded the service. Confirm that key and certificate still match after any renewal — a mismatched pair fails at handshake with an opaque error:
openssl x509 -noout -modulus -in fullchain.crt | openssl md5 openssl rsa -noout -modulus -in example.key | openssl md5 # the two hashes must be identical
Protocol and cipher mismatches
When a server disables TLS 1.0 and 1.1 — as it should — older clients fail during negotiation, before any certificate is examined. The error mentions protocol or handshake rather than trust:
openssl s_client -connect host:443 -tls1_2 < /dev/null openssl s_client -connect host:443 -tls1_3 < /dev/null # what the server will accept nmap --script ssl-enum-ciphers -p 443 www.example.com
Two error strings are worth recognising on sight:
no protocols availableorwrong version number— protocol mismatch; the client and server share no enabled version.sslv3 alert handshake failure— despite the name, almost always a cipher mismatch or a missing client certificate, not SSLv3.
If the server requests a client certificate and the client sends none, many servers abort with exactly that alert. Check for Acceptable client certificate CA names in the s_client output — its presence means mutual TLS is expected.
Trust stores differ per runtime
"It works with curl but not from the application" is usually two different trust stores rather than two different behaviours:
| Runtime | Trust store |
|---|---|
| OpenSSL / curl (RHEL) | /etc/pki/tls/certs/ca-bundle.crt |
| OpenSSL / curl (Debian) | /etc/ssl/certs/ca-certificates.crt |
| Java | $JAVA_HOME/lib/security/cacerts |
| Python requests | certifi bundle, independent of the OS |
| Node.js | Compiled-in list unless NODE_EXTRA_CA_CERTS is set |
| Containers | The image's own store — frequently minimal or stale |
# add a private CA, RHEL family cp internal-ca.crt /etc/pki/ca-trust/source/anchors/ update-ca-trust extract # Debian family cp internal-ca.crt /usr/local/share/ca-certificates/ update-ca-certificates
Updating the OS store does not update Java or Python. Each needs its own import, which is why a private CA rollout that looks complete still breaks one application.
Checklist
- Always pass
-servername; without SNI you are testing the wrong certificate. - Read the verify return code first — it classifies the fault immediately.
- Count the certificates sent. One means a missing intermediate, whatever the browser says.
- Check SAN, not CN, and remember a wildcard does not cover the apex.
- Check
notBeforeas well asnotAfter, and check NTP on both ends. - Compare the certificate on disk with the one being served to catch a renewal without reload.
- Confirm the key and certificate moduli match.
- If it fails only in one runtime, suspect that runtime's trust store before the certificate.
openssl s_client -connect host:443 -servername host answers most TLS questions in one command. Verify code 21 means a missing intermediate, code 10 means expiry, code 18 means self-signed, and a hostname error means SAN — browsers hide the first of these by caching intermediates, which is exactly why automated clients fail when browsers do not.