Incomplete certificate chain: why some clients reject a valid certificate
· tls · certificates · ssl · openssl · nginx
tlscertificatessslopensslnginxAn incomplete certificate chain means your server sends its leaf (end-entity) certificate but omits one or more intermediate CA certificates needed to build a path from the leaf up to a trusted root. The certificate itself is valid — it just can't be verified by clients that can't fetch the missing link. Browsers usually paper over the problem by caching intermediates or fetching them via the certificate's AIA extension. Many non-browser clients do not, which is why the site "works in Chrome" but breaks for everyone else.
Who actually fails
The clients that reject an incomplete chain are the ones you don't watch every day: older Android (which lacks the trust-store updates and AIA fetching newer versions have), Java/OpenJDK, and command-line tools like curl and wget. The classic symptom from curl is:
curl: (60) SSL certificate problem: unable to get local issuer certificate
OpenSSL phrases the same condition as Verify return code: 20 (unable to get local issuer certificate). Your CI pipeline, your mobile app, and your webhook callers all hit this. Browsers hide it, so the first report often comes from a partner's server-to-server integration failing in production.
Why it happens
Three causes account for nearly all of them:
- Only the leaf was installed. Someone pointed the server at the certificate file the CA emailed and never appended the intermediate(s). The single most common case.
- Misordered chain. TLS requires the leaf first, then each issuing certificate toward the root. Some clients are strict about order and stop building the path when it breaks.
- An expired or wrong cross-signed intermediate. Many roots are cross-signed: a newer root is also signed by an older, more widely-trusted root so old devices still trust it. Ship the wrong cross-sign — or a cross-sign whose bridging root has expired (the 2021 Let's Encrypt DST Root CA X3 expiry broke exactly this) — and old clients can't reach a root they trust.
Serve the full chain
The fix is to send the leaf plus every intermediate, in order. The root is optional and conventionally omitted — the client already has it, and sending it just wastes bytes.
In nginx, point ssl_certificate at the fullchain file your CA (or certbot) produced, not the bare leaf:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;In Apache, set SSLCertificateFile to a combined file holding the leaf followed by the intermediates (modern Apache 2.4.8+ no longer needs the separate SSLCertificateChainFile):
SSLCertificateFile /etc/ssl/example.com.fullchain.pem
SSLCertificateKeyFile /etc/ssl/example.com.keyDiagnose it from the command line
Ask the server what it actually sends:
openssl s_client -connect example.com:443 -servername example.com
Read two things. The Certificate chain block lists each cert with its subject (s:) and issuer (i:); the i: of one entry should match the s: of the next, all the way up. If that ladder stops before a root, an intermediate is missing. Then check Verify return code: near the bottom — 0 (ok) is what you want; 20 or 21 confirms a broken chain. Note that s_client uses your local trust store, so it can succeed even when a thin-trust-store client would fail — test the path the chain builds, not just the final verdict.
Further reading
- Reading a TLS certificate: fields, chains, and what to check
- Detecting unauthorized certificates with CT logs
- RFC 5280 — Internet X.509 Public Key Infrastructure Certificate path validation