Public CAs do not sign your certificate with their root key. The root signs an intermediate, and the intermediate signs your leaf certificate. Clients ship the root in their trust store; they do not ship your CA's intermediates. That is your server's job. If you only install the leaf, every client that cannot obtain the intermediate some other way sees an untrusted certificate.
Why does the server have to send the intermediate?
TLS path building works backwards from your leaf certificate to a trusted anchor. The client reads the leaf's issuer name, looks for a certificate with that subject, checks the signature, and repeats until it reaches something in its local trust store. A missing link stops the walk. OpenSSL reports this precisely: "unable to get local issuer certificate" (verify error 20) followed by "unable to verify the first certificate" (verify return code 21).
TLS specifies the wire format precisely: the leaf comes first, and each certificate after it should directly certify the one before it. The self-signed root may be omitted, and normally is, because sending it wastes bytes on every handshake. Cross-signed CAs and multi-tier hierarchies mean a chain of three or four certificates is entirely normal — example.com, for instance, presents its leaf plus two intermediates beneath an SSL.com root.
Why does it work in my browser but fail everywhere else?
This is the part that wastes hours. An incomplete chain is not uniformly broken — it is broken *per client*, because clients differ in how hard they try to repair a chain themselves.
- AIA fetching. Leaf certificates carry an Authority Information Access extension with a
CA IssuersURL pointing at the missing intermediate. Clients built on the Windows and macOS system trust stores will download it and complete the chain silently — Chrome, Safari, Edge, and any tool linked against those platform libraries. Your incomplete chain looks perfect. - Cached and preloaded intermediates. Firefox ships and caches a large set of known intermediates, so a chain that fails on a clean machine can succeed on yours simply because you visited another site issued by the same CA.
- OpenSSL-based clients do not repair anything.
openssl s_client, curl built against OpenSSL, most Linux distributions, Java, Go, Pythonrequests, Node.js, and virtually every webhook consumer and API client will simply fail. So will most mail clients doing STARTTLS validation. - Mobile and IoT clients. Older Android and embedded stacks have smaller trust stores and no AIA fetching. They fail hardest and report the least useful errors.
openssl s_client is the reference implementation of "no help offered".How do I detect an incomplete chain?
Ask the server what it actually sends. -showcerts prints every certificate in the presented chain; the summary block at the top lists subject (s:) and issuer (i:) for each position. A healthy server shows position 0 as your leaf and at least one more position whose subject matches the leaf's issuer.
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
| grep -E "Certificate chain|^ [0-9] s:|^ [0-9] i:"
# Healthy output:
# Certificate chain
# 0 s:CN=example.com
# 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
# 2 s:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2A broken server shows position 0 and nothing else, with a verification error. This is exactly what a deliberately misconfigured host looks like:
echo | openssl s_client -connect incomplete-chain.badssl.com:443 \
-servername incomplete-chain.badssl.com 2>&1 | grep -E "verify error|Certificate chain|^ [0-9] s:|Verify return code"
# verify error:num=20:unable to get local issuer certificate
# verify error:num=21:unable to verify the first certificate
# Certificate chain
# 0 s:CN=*.badssl.com
# Verify return code: 21 (unable to verify the first certificate)To list the full subject/issuer pairs of everything the server sent — useful when you suspect the certificates are present but in the wrong order — pipe the PEM blocks through pkcs7:
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
| openssl crl2pkcs7 -nocrl -certfile /dev/stdin \
| openssl pkcs7 -print_certs -nooutOur checker runs the same validation from a clean environment with no cached intermediates and no AIA fetching, so it sees what your least forgiving client sees.
How do I fix it in nginx?
nginx has no separate chain directive. ssl_certificate must point at a single PEM file containing the leaf first, then the intermediates. Order matters: nginx sends the file contents in the order it finds them, and clients are entitled to reject an out-of-order chain.
# Build it if your CA gave you separate files:
cat example.com.crt intermediate.crt > /etc/ssl/certs/example.com.fullchain.crt
# Certbot already produces this as fullchain.pem — use it, not cert.pem.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}ssl_certificate at cert.pem instead of fullchain.pem is the single most common cause of this problem. cert.pem is the leaf alone.How do I fix it in Apache?
Since Apache 2.4.8, SSLCertificateFile accepts a file containing the leaf plus intermediates, and SSLCertificateChainFile is deprecated. On 2.4.8 and later, use the concatenated file. On older builds you must keep the two directives separate.
# Apache 2.4.8 and later
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
# Apache before 2.4.8 only
SSLCertificateFile /etc/ssl/certs/example.com.crt
SSLCertificateChainFile /etc/ssl/certs/intermediate.crt
SSLCertificateKeyFile /etc/ssl/private/example.com.keyWhat else causes this on a server that looks correctly configured?
- A load balancer or CDN in front. The chain that matters is the one the edge terminates TLS with, not the one on your origin. Test the public hostname, and check where TLS actually terminates with the Redirect Checker and HTTP Headers Checker.
- Multiple vhosts, one wrong. Each
serverblock has its ownssl_certificate. Test every hostname on the IP, not just the primary — a request without SNI falls through to the default vhost. - Renewal that overwrote the file. Automation that copies only the leaf back into place will break the chain on renewal day, months after you last touched anything.
- A CAA record blocking reissuance. If a reissue silently failed and you are still serving an old certificate, check what your zone permits with the CAA Record Lookup.
- Reload versus restart. nginx and Apache both cache certificates in memory. Fix the file, then reload the service, then re-test.
Once the chain validates, confirm the expiry dates are what you expect using Check SSL Expiry with OpenSSL. If you arrived here from a browser error, note that a chain problem in Chrome usually shows as ERR_CERT_AUTHORITY_INVALID, not ERR_CERT_DATE_INVALID — see Fix ERR_CERT_DATE_INVALID for that one.
One last check worth running before you close the ticket: make sure every hostname that resolves to the box is fixed, not just the one that was reported. Enumerate the addresses behind each name with the A Record Lookup and the aliases pointing at them with the CNAME Lookup, then test each hostname individually. Chains break per vhost, and the one nobody reported is the one your API clients are hitting.
The same failure applies outside HTTPS. A mail server presenting an incomplete chain on port 25 or 587 will be rejected by any sender that requires validated TLS, including anyone honouring your MTA-STS policy — check it with the MTA-STS & TLS-RPT Checker and test the submission ports themselves with the SMTP Connection Test.