RCWRCW IT TrainingFree hands-on labs & simulators← Back to home
Networking · Detailed troubleshooting guide

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.

Published October 6, 2026 · By , Enterprise Infrastructure Architect

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:

CodeMessageCause
0okValid
2unable to get issuer certificateIssuer not present and not in the local store
10certificate has expiredExpiry — check the clock too
18self signed certificateSelf-signed leaf
19self signed certificate in chainPrivate CA not trusted by the client
20unable to get local issuer certificateClient has no path to a trusted root
21unable to verify the first certificateServer did not send its intermediate
62hostname mismatchName 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.com matches www.example.com but not example.com itself — the apex needs its own SAN entry.
  • Wildcards match exactly one label. *.example.com does not match a.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 available or wrong 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:

RuntimeTrust 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 requestscertifi bundle, independent of the OS
Node.jsCompiled-in list unless NODE_EXTRA_CA_CERTS is set
ContainersThe 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

  1. Always pass -servername; without SNI you are testing the wrong certificate.
  2. Read the verify return code first — it classifies the fault immediately.
  3. Count the certificates sent. One means a missing intermediate, whatever the browser says.
  4. Check SAN, not CN, and remember a wildcard does not cover the apex.
  5. Check notBefore as well as notAfter, and check NTP on both ends.
  6. Compare the certificate on disk with the one being served to catch a renewal without reload.
  7. Confirm the key and certificate moduli match.
  8. If it fails only in one runtime, suspect that runtime's trust store before the certificate.
Key takeaway: 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.