Incomplete Certificate Chain: Why Your Site Works in Chrome but Fails in curl
An incomplete certificate chain is why your site works in Chrome but fails in curl, Java or Android. How to diagnose it with openssl and fix it properly.
An incomplete certificate chain is the classic "works on my machine" of TLS: the site loads perfectly in Chrome, yet curl throws unable to get local issuer certificate, the Java service integrating with your API fails with PKIX path building failed, and older Android phones show a security warning. Nothing expired, nothing is misissued — your server simply isn't sending the full chain, and some clients are quietly papering over the gap while others refuse to.
What the certificate chain actually is
Browsers don't trust your certificate directly. They trust a small set of root CAs shipped in the OS or browser trust store, and your certificate has to be cryptographically traceable back to one of them:
- Leaf certificate — issued for your hostname(s). This is "your" cert.
- Intermediate certificate(s) — issued by the root (or another intermediate), used by the CA to sign leaves. Roots are kept offline; intermediates do the day-to-day signing. Let's Encrypt's leaves, for instance, are signed by intermediates such as R-series certs, not directly by the ISRG root.
- Root certificate — self-signed, pre-installed in the client's trust store.
To validate your leaf, the client must build the path leaf → intermediate(s) → trusted root. The TLS standard expects the server to send the leaf plus all intermediates (the root itself is unnecessary — the client already has it, and sending it just wastes bytes). An incomplete certificate chain means the server sent the leaf alone, leaving the client to figure out the middle on its own.
Why it "works in Chrome but fails in curl": AIA fetching
Here's the part that makes this bug so deceptive. Certificates carry an Authority Information Access (AIA) extension containing a URL where the issuing intermediate can be downloaded. Some clients, on encountering an unknown issuer, will go and fetch it:
| Client | Fetches missing intermediates? |
|---|---|
| Chrome / Edge (desktop) | Yes — AIA fetching |
| Safari / macOS / iOS | Yes |
| Firefox | No AIA fetch, but caches intermediates it has previously seen, so it often works anyway |
| curl / OpenSSL | No |
Java (PKIX path building failed) | No |
Python requests / Go / Node | No |
| Android (many versions/apps) | No |
| Most API clients, webhooks, payment callbacks, monitoring agents | No |
So the population that can't cope with your incomplete chain is precisely the population that won't file a bug report: machine-to-machine traffic. Mobile apps, partner integrations, webhook deliveries and scrapers just start failing TLS validation. Meanwhile every human who checks the site in a desktop browser declares it fine. We routinely see this surface as "the API works from my laptop but the customer's backend can't connect" — and the customer's backend is right.
Firefox's behaviour adds a second trap: a misconfigured server can appear to work in Firefox if that profile previously visited another site that served the same intermediate. Two colleagues, same browser, different results.
Diagnosing with openssl s_client
Don't guess — look at exactly what the server sends:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
Read the output top-down:
- The
Certificate chainsection lists every certificate the server sent, numbered from 0 (the leaf). A healthy response shows the leaf at0and one or more intermediates after it, where each entry'si:(issuer) matches the next entry'ss:(subject). If you only see entry0, the chain is incomplete. verify error:num=21:unable to verify the first certificate(ornum=20: unable to get local issuer certificate) near the top is OpenSSL telling you it couldn't build the path from what was sent.Verify return code: 0 (ok)at the bottom is what you want to see once fixed.
Two notes: always pass -servername so SNI selects the same certificate a real client gets, and remember openssl is also the right tool to confirm the fix, since browsers will lie to you via AIA and caching. Online checkers (SSL Labs flags this as "Chain issues: Incomplete") are a good second opinion.
Fixing it on common servers
The fix is always the same idea — serve the leaf followed by the intermediates — but each server spells it differently.
nginx. ssl_certificate must point at a file containing the leaf first, then intermediates. If you use certbot, that file already exists: use fullchain.pem, never cert.pem:
| Server | Correct configuration |
|---|---|
| nginx | ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; |
| Apache ≥ 2.4.8 | SSLCertificateFile pointing at the full-chain file (SSLCertificateChainFile is deprecated) |
| HAProxy | Single PEM bundle per cert: leaf + intermediates (+ key) concatenated |
| Caddy / cert-manager / managed load balancers | Handle the chain automatically — but verify after migrations |
If your CA gave you separate files, concatenate them in order — leaf first:
cat example.com.crt intermediate.crt > example.com.fullchain.crt
Common ways this regresses after being fixed once: a renewal pipeline that installs cert.pem instead of fullchain.pem; a manual cert replacement during an incident where someone pastes only the leaf into a load balancer console; an appliance or CDN config that has separate "certificate" and "chain" fields and the second one gets skipped; or a CA changing its intermediate structure at renewal while your config still ships an old, now-irrelevant intermediate file.
Catch it before your integrations do
Because browsers mask the problem, an incomplete chain can sit in production for months until a client library upgrade or a new partner integration trips over it. The reliable countermeasure is the same as for expiry: external, continuous validation of what each endpoint actually serves — including full chain verification, not just a date check — so a bad renewal or a hasty load-balancer change gets flagged the same day.
DomainOps validates the complete certificate chain on every endpoint check, so "works in Chrome, fails in curl" gets caught before your webhooks and API consumers find it — see endpoint monitoring for how checks are configured.