Fixing SSL certificate chain errors: valid in the browser, broken everywhere else
It is one of the most confusing failures in TLS: the site loads with a padlock in every browser, but curl refuses to connect, the mobile app throws a trust error, and a payment provider's webhook delivery fails with a certificate complaint. The certificate is not expired. It is not for the wrong hostname. The error, when you finally see it spelled out, is usually this:
SSL certificate problem: unable to get local issuer certificate
Nine times out of ten, the server is sending an incomplete certificate chain, and the browser has been quietly papering over it.
What a chain actually is
Your server's certificate (the leaf) is not signed directly by a root certificate authority. Roots are kept offline and used sparingly, so CAs sign an intermediate certificate with the root, and the intermediate signs your leaf. Trust flows leaf → intermediate → root.
A client validating your certificate needs the whole path. It has the roots already, shipped in the operating system or browser trust store. It receives your leaf during the TLS handshake. The intermediate is the piece in the middle that has to come from somewhere, and the TLS specification is clear about where: the server is supposed to send it, immediately after the leaf, in the handshake.
When a server sends only the leaf, the chain is incomplete. The client holds a certificate signed by an intermediate it has never seen, cannot connect it to any root it trusts, and fails with exactly the error above.
Why browsers hide the problem
Browsers go out of their way to make broken configurations work. Certificates carry an Authority Information Access (AIA) extension with a URL where the issuing intermediate can be downloaded, and Chrome, Safari, and Edge will fetch it on the fly when a server fails to send it. Browsers also cache every intermediate they have ever seen, so a popular CA's intermediate is almost always already on disk. Firefox does no AIA fetching but ships with a preloaded set of common intermediates, which has much the same effect.
Almost nothing else does any of this. OpenSSL does not fetch AIA. Neither does curl, Python's requests, Go's crypto/tls, Java's default trust manager, or the TLS stacks in most mobile apps. They validate strictly with what the server sent plus the local root store, which is the correct behavior; AIA fetching adds a plaintext HTTP dependency into certificate validation.
This split is the entire explanation for "works in the browser, fails in curl." The browser repaired your chain for you. Everything else reported the truth.
Diagnosing it
The direct way is to ask the server what it sends:
openssl s_client -connect example.com:443 -servername example.com -showcerts
Look at the certificate list near the top of the output. A correct configuration shows the leaf at depth 0 and at least one intermediate after it:
Certificate chain
0 s:CN = example.com
i:C = US, O = Let's Encrypt, CN = R13
1 s:C = US, O = Let's Encrypt, CN = R13
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
If you see only entry 0 and the verification result says unable to get local issuer certificate or unable to verify the first certificate, the server is not sending the intermediate. The -servername flag matters: without SNI you may be handed the default virtual host's certificate and diagnose the wrong site.
If you would rather not reach for openssl, the SSL checker runs the same kind of inspection against any host and flags an incomplete chain explicitly, alongside expiry and hostname checks. To inspect a certificate file you already have locally, the SSL decoder parses it in the browser without uploading it anywhere.
The fix: serve the full chain
The repair is configuration, not cryptography: point the server at a file containing the leaf followed by the intermediates.
Let's Encrypt makes this nearly impossible to get wrong, yet it remains the most common place people get it wrong. Certbot writes four files into /etc/letsencrypt/live/yourdomain/:
cert.pem— the leaf onlychain.pem— the intermediates onlyfullchain.pem— leaf plus intermediates, in orderprivkey.pem— the private key
For nginx, the certificate directive must use fullchain.pem:
ssl_certificate /etc/letsencrypt/live/yourdomain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain/privkey.pem;
Pointing ssl_certificate at cert.pem is the classic mistake, and it produces precisely the browser fine, curl broken symptom. Apache since 2.4.8 behaves the same way: give SSLCertificateFile the full chain. For certificates from a commercial CA, the CA emails or lets you download a bundle file; concatenate it after your certificate, leaf first.
Do not include the root in the file. Clients must already have the root or the connection is unverifiable anyway, sending it wastes a kilobyte per handshake, and some strict clients object to it. The chain you serve should end at the last intermediate. When renewing or replacing a certificate, generating the CSR is also a place where chain confusion starts, so it is worth doing cleanly; the CSR generator builds one in the browser, and the key never leaves your machine.
The other chain failure: expired roots
A complete chain can still fail validation if the client's trust store is old, and the canonical example is September 30, 2021, when the DST Root CA X3 certificate expired. Let's Encrypt had been cross signed by that root to support old devices, and its modern root, ISRG Root X1, was not present in older trust stores. On that day, otherwise valid Let's Encrypt certificates started failing on old Android versions, devices with stale CA bundles, and clients built on OpenSSL 1.0.2, which had a bug where one expired path poisoned validation even when a second valid path existed.
The lesson generalizes. Chain problems are not always your server's fault; sometimes the client's root store is the broken half. But you control exactly one side of that handshake, so the practical rule stands: send the complete chain, leaf first, intermediates after, no root, and verify it from the outside with the SSL checker rather than trusting what your browser shows you. The browser is the one client guaranteed to lie politely.