TLS Certificate Chain Problems: Diagnosing with OpenSSL - 夜莺博客

TLS Certificate Chain Problems: Diagnosing with OpenSSL

"Certificate error" is a symptom, not a diagnosis. The certificate may be expired, the chain may be missing an intermediate, the client may be sending the wrong SNI, or the server may be presenting a chain that only validates on the machine you tested from. OpenSSL's s_client answers all of those in one command, which is why it is still the first tool to reach for.

Fetch and Verify in One Step

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
openssl s_client -connect example.com:443 -servername example.com -verify_return_error </dev/null

Read three things in the output: the Verify return code line, the chain that is printed after Certificate chain, and the negotiated protocol and cipher. A verify code of 0 (ok) means the chain validated against your local trust store - nothing more. unable to get local issuer certificate means the server did not send an intermediate the client needs, or your trust store lacks the root.

The Three Flags That Matter

  • -servername example.com sends SNI. Without it, a server hosting many names hands back its default virtual host certificate, and you spend twenty minutes debugging a certificate that is perfectly valid for a different site.
  • -showcerts prints every certificate the server sent, so you can see the order of the chain and whether an intermediate is missing.
  • -verify_return_error makes the verification failure fatal. Without it, s_client prints the error, then happily continues to print the rest of the session — which is how people miss the failure in the first place.

For a one-line answer that is safe to paste into a ticket, add -brief on OpenSSL 3.x, which collapses the output to the protocol, cipher, verification status and expiry. And if the handshake fails before any certificate is exchanged, the diagnosis needs a different set of flags:

# Print every handshake message and the state machine transitions
openssl s_client -connect example.com:443 -servername example.com -state -msg </dev/null

# Force a specific protocol version to prove a TLS 1.0/1.1 or 1.2 negotiation problem
openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null

# Show which signature algorithms and extensions the server accepts
openssl s_client -connect example.com:443 -servername example.com -tlsextdebug </dev/null

A handshake that dies immediately with no peer certificate available is almost always a protocol or cipher mismatch, a middlebox interfering with the ClientHello, or a listening socket that is not actually TLS — not a certificate problem at all.

Common Failures and What They Mean

  • unable to get local issuer certificate - missing intermediate in the server's chain (the classic misconfiguration: server sends leaf plus root, or leaf only).
  • certificate has expired / not yet valid - check the validity window with openssl x509 -noout -dates -in cert.pem and confirm the clock on both endpoints. A wrong system clock produces exactly the same error as an expired certificate.
  • hostname mismatch - the requested name is not in the subject alternative names. CN-only certificates are no longer sufficient for browsers.
  • self-signed certificate in chain - an internal CA is in use and the client has no trust anchor for it; correct for lab systems, wrong for anything public.
  • certificate chain too long - a cross-signed root is in the bundle and the client is rejecting the extra path length; rare, but it happens with older appliance trust stores.
  • unable to verify the first certificate - the server sent only the leaf, and the intermediate is nowhere on your client either. Verify code 20 and 21 look similar and mean different things: 20 is "you are missing the intermediate", 21 is "you are missing the root".

The Error Codes Worth Memorising

openssl s_client -connect example.com:443 -servername example.com 2>&1 | grep -E 'Verify return code|verify error'
  • 0 (ok) - the chain validated. It does not mean the certificate is the right one, unexpired, or trusted by browsers.
  • 10 certificate has expired - check the server clock as well as the certificate dates.
  • 18 self-signed certificate - the chain terminates at a root the client does not carry, or at the leaf itself.
  • 19 self-signed certificate in certificate chain - an internal CA is in the path.
  • 20 unable to get local issuer certificate - intermediate missing from the server's chain.
  • 21 unable to verify the first certificate - the leaf is signed by something the client has no reference point for at all.
  • 62 hostname mismatch - SNI or SAN problem, not a trust problem.

Inspect a Certificate File Locally

openssl x509 -in server.crt -noout -subject -issuer -dates -ext subjectAltName
openssl rsa -in server.key -check
openssl x509 -noout -modulus -in server.crt | openssl md5
openssl rsa  -noout -modulus -in server.key | openssl md5
openssl verify -CAfile ca-bundle.pem -untrusted intermediate.pem server.crt

The modulus comparison is the fastest way to answer "does this key match this certificate?" - the two MD5 values must be identical. openssl verify with -untrusted lets you test the exact chain a server should be sending, before you change anything in production.

For anything the modulus check does not settle, add the distinguishing details - serial, fingerprints, and the extensions a modern client actually enforces:

# Everything about the certificate in one shot
openssl x509 -in server.crt -noout -text

# Serial number and SHA-256 fingerprint, for matching against what a client sees
openssl x509 -in server.crt -noout -serial -fingerprint -sha256

# Which names the certificate is valid for, one per line
openssl x509 -in server.crt -noout -ext subjectAltName

# Is this certificate a CA or an end entity, and how deep can it sign?
openssl x509 -in ca.crt -noout -ext basicConstraints -ext keyUsage

# Verify a leaf against a root with the intermediate supplied explicitly
openssl verify -CAfile root.pem -untrusted intermediate.pem -verify_hostname example.com server.crt

The -verify_hostname option matters because openssl verify by default only checks the chain, not the name. A certificate that chains perfectly but covers the wrong name passes a plain openssl verify and fails in every browser — two different checks, and you need to run both.

Assembling and Testing the Chain You Send

Most real-world failures are a server sending an incomplete chain, and the fix is almost always file order. Certificate files are read in order, leaf first, and the server sends everything in the file; the root should generally be omitted because the client already has it.

# Build the chain file: leaf, then intermediate(s)
cat server.crt intermediate.crt > fullchain.pem

# Confirm how many certificates are in it and in what order
grep -c 'BEGIN CERTIFICATE' fullchain.pem
openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout

# Prove the assembled chain validates against the real root
openssl verify -CAfile root.pem -untrusted intermediate.pem server.crt

# Prove the chain the *server* sends is correct, end to end
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>&1 | \
  openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt

If you use Let's Encrypt, the trap has a name: certbot writes both chain.pem (intermediate only) and fullchain.pem (leaf plus intermediate). Point nginx's ssl_certificate at fullchain.pem, not cert.pem. Pointing at cert.pem is the single most common cause of "works in Firefox, fails in curl and Java" reports. The reverse-proxy side of that configuration is covered in nginx reverse proxy setup and TLS-terminating NGINX stream proxies.

Trust Stores, Not Cipher Suites

When the failure is that one client cannot verify a working site, the problem is usually local trust rather than the server. The check is the same on every Linux distribution, and the fix depends on which store the application actually reads:

# Does the client carry the root that signed the server's chain?
openssl s_client -connect example.com:443 -servername example.com -CAfile /etc/ssl/certs/ca-certificates.crt </dev/null

# OpenSSL's own default locations, including its hashed certificate directory
openssl version -d
openssl s_client -connect example.com:443 -servername example.com -CApath /etc/ssl/certs </dev/null

# Which roots are present, browsed non-interactively
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -i 'issuer' | head

On Debian and Ubuntu the store is rebuilt after adding a CA with update-ca-certificates; on RHEL and derivatives, update-ca-trust. Adding a PEM file to /usr/local/share/ca-certificates/ without running the update command is a very common half-fix — the file is there, the store is unchanged, and everything still fails. Crucially, every application with its own bundled trust store (Java's cacerts, Python's certifi, a Go binary, a container image) is a separate problem that no system package update will touch. That is why the same certificate works from the shell and fails from an application.

Revocation and Stapling

# Ask the server whether it staples an OCSP response
openssl s_client -connect example.com:443 -servername example.com -status </dev/null | grep -A3 'OCSP response'

# Check revocation for a leaf against its issuer directly
openssl ocsp -issuer intermediate.crt -cert server.crt -url http://ocsp.issuer.example -resp_text

# Inspect a CRL if the CA publishes one
openssl crl -in ca.crl -noout -lastupdate -nextupdate -text | head -20

A missing OCSP response is not a failure — most clients fall back to soft-fail — but a stale or revoked response is a hard failure at some clients and not others. If a site works from your laptop and fails from a corporate network, revocation checking is high on the list of suspects, along with the transparency that a local config is easier to overlook than it sounds; the DNS-side counterpart of "works from one resolver and not another" is covered in systemd-resolved and resolvectl troubleshooting.

Chains on Network Infrastructure

Management planes hit the same problems: web UIs, NETCONF/RESTCONF APIs and SNMPv3-to-TLS gateways all depend on a chain the client trusts. Internal CAs are normal here - the requirement is to distribute the root deliberately rather than disabling verification. Where certificates are provisioned across device fleets, automate the renewal and the trust-store distribution; the API-based approaches are described in NETCONF, RESTCONF and gNMI compared, and out-of-band management interfaces (which frequently carry their own self-signed certificate) in iDRAC versus iLO versus IPMI. If your TLS endpoint is a DNS service, the zone and DNSSEC side is in BIND 9 authoritative zone configuration. When the endpoint is a Kubernetes ingress or Gateway instead, the failure mode moves to the controller — the certificate concepts stay identical, as described in Gateway API versus Ingress migration.

A Triage Checklist

  1. Reproduce with SNI: openssl s_client -connect host:443 -servername host -verify_return_error. If you did not set -servername, throw the result away.
  2. Read the verify code and map it to a cause before touching anything.
  3. Count the certificates in the chain. Fewer than two for a public site means an intermediate is probably missing.
  4. Check dates on both the leaf and the intermediates — intermediates expire too, and often more quietly than leaves.
  5. Check the clock on the client and the server. date -u on both, and be suspicious of NTP drift on appliances.
  6. Confirm the intended name is in the SAN list, not just the CN.
  7. Verify the key matches the certificate with the modulus comparison before blaming anything else.
  8. Only then look at protocol versions and ciphers, and only then consider -state -msg output.
  9. Never "fix" a chain problem by disabling verification in the client — fix the chain or distribute the root.

原文链接:https://www.openssl.org/docs/manmaster/man1/openssl-s_client.html