The short version
Your server is sending only the leaf certificate, not the intermediate that signed it. A client has to build a path from your leaf to a root it already trusts, and it cannot do that with a link missing. Browsers repair the gap on the fly: Chrome, Safari and Windows download the missing intermediate from a URL embedded in your certificate, Firefox has every public intermediate preloaded, and all of them cache intermediates they saw on other sites. Almost nothing else does. curl, wget, Python, Java, Go, Node, Ruby, PHP, Android apps and every embedded device take what the server sends, find it does not reach a root, and stop.
You can confirm it in ten seconds. Put the host into the SSL certificate chain checker: if the "Chain" tile says incomplete and the verdict table is green for browsers and red for everything else, this is your problem, and the corrected fullchain.pem is at the bottom of the page.
What the client is actually doing
A public certificate is not trusted on its own. It is trusted because it was signed by an intermediate CA certificate, which was signed by a root CA certificate, and the root is in the client's trust store. The client has the root. The server is responsible for sending the leaf and every intermediate between the leaf and the root. That has been the rule since TLS 1.0, and every certificate authority's installation instructions say it, usually on a page nobody reads because the browser already showed a padlock.
When the intermediate is missing, a strict client sees a leaf signed by "R11" or "Sectigo RSA Domain Validation Secure Server CA" or whatever the intermediate is called, looks for that name in its trust store, does not find it (intermediates are not roots and are not in there), and gives up. The error is some form of "I cannot find the issuer of this certificate":
| Client | What it says |
|---|---|
| curl, wget, PHP | SSL certificate problem: unable to get local issuer certificate |
| openssl s_client | verify error:num=20:unable to get local issuer certificate |
| Python requests | [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate |
| Java | PKIX path building failed: unable to find valid certification path to requested target |
| Node.js | UNABLE_TO_VERIFY_LEAF_SIGNATURE: unable to verify the first certificate |
| Go | x509: certificate signed by unknown authority |
| Android | Trust anchor for certification path not found |
| Chrome, Safari, Firefox, Edge | Nothing. Padlock. |
Same fault, seven vocabularies. Each of those strings has its own search results, its own Stack Overflow thread and its own wrong answers, most of which involve turning verification off. If you are here from one of them, what unable to get local issuer certificate actually means works through the diagnosis, because about half the time the fault is on the client and not the server.
How browsers get away with it
AIA fetching
Every public leaf certificate carries an Authority Information Access extension with a caIssuers URL, something like http://r11.i.lencr.org/. It is a plain HTTP link to the intermediate that signed the leaf. Chrome, Safari and Windows Schannel follow it when the served chain does not build. That download takes a round trip nobody notices, and it is why the padlock appears. It is also why the same page loads in Chrome on the machine where curl just failed: they are not using the same verifier.
Intermediate preloading
Firefox does not fetch, but since 2020 it ships every intermediate that CAs have disclosed to Mozilla's programme, which is all of them for a publicly trusted CA. So it has your intermediate before it has ever seen your site. Same result, different route.
Caching
All browsers keep intermediates they have seen. Let's Encrypt's intermediates sign a very large share of the web, so a browser that has been anywhere today has them cached. This is the one that fools people during testing: "it works on my machine" is true, and it works because the machine had been to another Let's Encrypt site an hour ago.
None of this is available to a Python script in a container that was built this morning, to a Java service with its own cacerts, to a payment provider's webhook sender or to the TLS stack in a smart TV. They get what your server sends.
How it happens
The CA sent you two or three files and only one went into the server config. That is nearly always the whole story. The variations:
- Certbot writes
cert.pem(leaf only),chain.pem(intermediates only) andfullchain.pem(both). The server was pointed atcert.pem. nginx and Apache both accept it without complaint. - A commercial CA emailed
yourdomain.crtandca-bundle.crtorintermediate.crt. The first was installed. The second was not concatenated onto it, or on Apache older than 2.4.8 was not put inSSLCertificateChainFile. - A cloud load balancer (AWS ALB, CloudFront, Google, Azure) has a separate "certificate chain" box when you import your own certificate. It is optional in the form. It is not optional.
- A renewal produced a new leaf signed by a newer intermediate, and the deployment script copied only the leaf, leaving the old intermediate in place. Now the served intermediate is wrong rather than absent, which fails in exactly the same way.
- One edge out of several got the new files and the others did not, so the failure is intermittent and depends on which address DNS handed out.
How to see it for yourself
Do not use a browser to test a certificate deployment. Use something that shows what came over the wire. The chain checker does this from outside your network and lists every certificate the server sent, in order, with the missing one marked. From a shell:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERT' # 1 means leaf only. 2 or 3 means leaf plus intermediates. curl -vI https://example.com 2>&1 | grep -iE 'issuer|SSL certificate problem'
If you have several load balancers or a CDN, test each address separately: put the IP in the host field and the site name in SNI. The most common intermittent version of this fault is a chain that is right on three edges and wrong on the fourth.
The fix
Serve the leaf followed by every intermediate, in order, in one file, and no root. The chain checker builds that file for you from the correct path; how to build a correct fullchain.pem covers doing it by hand and the configuration line for each server. Then re-run the check and confirm the whole verdict column is green, not just the browser rows.
Two things not to do. Do not add the intermediate to the client's trust store; that fixes one machine and leaves every other client broken. And do not set verify=False, -k, NODE_TLS_REJECT_UNAUTHORIZED=0 or a trust-all TrustManager; that fixes the symptom by removing the security the certificate was for, and it will be found in production a year from now by someone who is not pleased.
Why this keeps coming back
Because the test that everyone runs, opening the site in a browser, is the one test that cannot detect it. Every other check in a deployment pipeline would catch a broken certificate. This one needs a non-browser client in the pipeline: a curl against the new endpoint before it goes live, or a monitor that uses a strict verifier. The domain health checker uses one, so an incomplete chain shows up in its TLS pillar rather than in an incident channel.