'Unable to get local issuer certificate': fix the chain, not the client
The site loads fine in Chrome. The padlock is there. Then a payment webhook starts failing, a mobile app throws a TLS error, and someone's Python script says the certificate could not be verified. Somebody suggests curl -k, it works, and now that flag is in a deploy script forever.
That is the moment worth stopping at. unable to get local issuer certificate almost never means the certificate is bad. It usually means your server is sending an incomplete chain, and the clients that still work are the ones lucky enough to already have the missing piece cached. Browsers paper over this. Nothing else does.
The fix is one line of nginx config. The damage from the popular workaround lasts years, because disabling certificate verification does not fail loudly; it just quietly makes every connection from that client trustable by anyone on the path.
nginx must serve the leaf certificate and the intermediates. With Let's Encrypt that means fullchain.pem, never cert.pem:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
Reload, then confirm what is actually on the wire, not what is on disk:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
A count of 1 means you are serving the leaf alone and this is your bug. You want 2 or more, and Verify return code: 0 (ok).
The exact errors, by client
The same server-side fault produces different text depending on what is connecting, which is why teams often fail to realise they are all looking at one problem.
curl: (60) SSL certificate problem: unable to get local issuer certificate
Verify return code: 21 (unable to verify the first certificate)
requests.exceptions.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1006)
Error: unable to get local issuer certificate
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
All five are one sentence in different accents: I cannot build a path from this certificate to a root I trust.
Why this happens
A public certificate is never trusted on its own. It is trusted because it was signed by an intermediate CA, which was signed by a root CA, which is in the client's trust store. That path from leaf to root is the chain, and somebody has to supply the middle of it.
Root certificates ship with the operating system, the browser, or a language runtime bundle. Intermediates do not. They change regularly, and CAs deliberately do not expect clients to have them. So the server is responsible for sending the intermediates alongside its own certificate. If it does not, a client with only the root has a leaf it cannot connect to anything, and reports exactly that.
Why the browser works and everything else fails
Browsers cache intermediates they have encountered on other sites, and most also support AIA fetching, where they download the missing intermediate from a URL embedded in the certificate. So a broken chain can look perfectly healthy in Chrome and fail in every server-to-server call you make.
This is why "it works in my browser" is worthless as a test here, and why the bug usually surfaces days after deployment, reported by an integration partner rather than a user.
The two OpenSSL codes are not the same thing
| Message | What it means | Who should fix it |
|---|---|---|
21 (unable to verify the first certificate) | The server sent a leaf with no intermediates. | The server operator. Serve the full chain. |
20 (unable to get local issuer certificate) | A certificate in the presented chain has an issuer the client cannot find. | Usually the server. Occasionally the client, if its trust store is genuinely out of date. |
10 (certificate has expired) | Something in the chain is past its notAfter. Often an old intermediate, not the leaf. | Renew, or update the chain file. |
19 (self signed certificate in certificate chain) | A private CA, or a corporate TLS-inspecting proxy sitting in the middle. | The client, by trusting that CA explicitly. |
Getting 21 tells you unambiguously that the server is at fault. Getting 19 on a corporate network tells you just as unambiguously that it is not.
The fix, step by step
Step 1: See what the server actually sends
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
The -servername flag is not optional. Without SNI, nginx serves whatever certificate belongs to the default server block, and you will spend twenty minutes debugging the wrong vhost. In the output, look at the Certificate chain section:
Certificate chain
0 s:CN=example.com
i:C=US, O=Let's Encrypt, CN=R11
1 s:C=US, O=Let's Encrypt, CN=R11
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Read it as a ladder: entry 0's issuer (i:) must be entry 1's subject (s:), and the last entry's issuer must be a root in the client's store. If the section shows only entry 0, the ladder has one rung and this is your bug.
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
Expected output: 2 for a typical Let's Encrypt setup. 1 means leaf only.
Step 2: Point nginx at the right file
Certbot writes four files into /etc/letsencrypt/live/example.com/ and the naming is a trap:
| File | Contents | Use in nginx |
|---|---|---|
cert.pem | The leaf certificate alone | Never. This is the cause of this bug. |
chain.pem | The intermediates alone | Only for ssl_trusted_certificate when stapling |
fullchain.pem | Leaf followed by intermediates | ssl_certificate |
privkey.pem | The private key | ssl_certificate_key |
cert.pem is the obvious-looking name and the wrong answer. Apache historically used a separate SSLCertificateChainFile directive, which is why so much copied config gets this wrong on nginx, where the chain goes in the same file.
server {
listen 443 ssl;
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_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
}
Step 3: Test the config and reload
sudo nginx -t && sudo systemctl reload nginx
Expected output: syntax is ok and test is successful. Note that nginx -t validates syntax and file readability, not chain completeness. It will happily approve a config that serves a leaf on its own, which is precisely why step 4 exists.
Step 4: If you are not using certbot, build the file yourself
Commercial CAs usually send you a leaf and one or more intermediate files. Concatenate them, leaf first, in order towards the root:
cat your_domain.crt intermediate.crt > /etc/nginx/ssl/example.com.fullchain.pem
Order matters, and getting it backwards produces a chain that some clients accept and others reject, which is a genuinely miserable class of bug. Verify locally before reloading:
openssl verify -untrusted intermediate.crt your_domain.crt
Expected output: your_domain.crt: OK.
Verification
- Confirm the served chain verifies:
Expected output:openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \ | grep "Verify return code"Verify return code: 0 (ok). Anything else means you are not finished. - Confirm with a client that has no cache:
Nocurl -sS -o /dev/null -w "%{http_code}\n" https://example.com/-k. If curl is happy without it, so will your webhooks be. - Test from a machine that has never talked to your server, ideally a container with a minimal CA bundle:
docker run --rm curlimages/curl -sS https://example.com/. This is the closest thing to how a payment provider's server sees you. - Check every vhost, not just the one you fixed. A server with six domains often has one that was configured by hand two years ago:
Any line ending insudo nginx -T | grep -n "ssl_certificate "cert.pemis a future incident.
What people get wrong
"Use curl -k." That flag disables verification entirely. It does not skip one check; it accepts any certificate from anyone, which means the connection is no longer authenticated and anybody who can intercept the traffic can read and modify it. Using it once to confirm a diagnosis is fine. Leaving it in a script that moves money is not, and that is exactly where these flags end up.
"Set NODE_TLS_REJECT_UNAUTHORIZED=0." This is worse than -k because it is process-wide. Every outbound TLS connection from that Node process, including to your database, your payment provider and your identity provider, stops being verified. Node prints a warning about it and people filter the warning out.
"Set verify=False in requests." Same failure, Python flavour. If you genuinely need to trust a private CA, pass the CA bundle: verify="/path/to/ca.pem". That is a targeted decision. False is not.
"Put the chain in ssl_trusted_certificate." A subtle one, and it looks plausible. That directive tells nginx which CAs to trust when verifying OCSP responses and client certificates. It does not affect what nginx sends to browsers. Setting it and nothing else changes nothing about your problem.
"Reissue the certificate." The certificate was never the problem. You will get an identical one and the same failure, having burned an issuance against your rate limit. If you are already fighting issuance itself, that is a different problem covered in the certbot connection refused article.
When it is still broken
- Check whether the failing client is the one at fault. If
openssl s_clientreturns 0 from your laptop but one particular server fails, that server's CA bundle is out of date. On Debian or Ubuntu:sudo apt install --reinstall ca-certificates && sudo update-ca-certificates. For Python, upgradecertifi. For Node behind a corporate proxy, pointNODE_EXTRA_CA_CERTSat the proxy's CA, which trusts one specific extra authority rather than abandoning verification. - Check for old clients and CA transitions. Let's Encrypt has changed its default chain more than once, and the expiry of the old DST Root CA X3 cross-sign in September 2021 broke a large population of Android devices and machines running very old OpenSSL. If your failures cluster on old hardware while modern clients are fine, that is the shape of the problem, and the answer is updating the client trust store rather than changing your server.
- Check what is really terminating TLS. If a CDN or load balancer sits in front of nginx, it is presenting the certificate, not you. Fixing the origin changes nothing. Point
s_clientat the origin IP directly with-servernameto compare the two. - Check for an expired intermediate. Certbot renews the leaf; the chain file it writes comes from the CA. A stale hand-built
fullchain.pemcan contain an intermediate that has since expired, producing verify code 10 while your leaf is perfectly fresh. Inspect every certificate in the file, not just the first.
The rule I would leave you with: when a TLS client complains, the first question is what the server is sending, and the second is what the client already trusts. Neither of those questions is answered by turning verification off. If your reflex on a certificate error is to add a flag that skips the check, you have converted an outage that lasts an hour into a security hole that lasts until someone audits the codebase. Fix the chain. It is one line. For the wider set of things that need to be right around it, see the nginx reverse proxy and TLS setup.
Frequently asked questions
- What does 'unable to get local issuer certificate' actually mean?
- It means the client could not build a trust path from the certificate it was given up to a root certificate authority it trusts, because an intermediate certificate in the middle of that path is missing. Root certificates ship with operating systems and browsers, but intermediates do not, so the server is responsible for sending them alongside its own certificate. In nearly every case the certificate itself is valid and the server is simply not sending the full chain.
- Why does my site work in Chrome but fail with curl and in my app?
- Browsers cache intermediate certificates they have seen on other sites and most can also fetch a missing intermediate from a URL embedded in the certificate. That masks an incomplete chain completely. Command line tools, language HTTP clients and mobile apps generally do neither, so they fail on a chain a browser accepts. This is why testing a certificate fix in a browser proves nothing.
- Should I use fullchain.pem or cert.pem in nginx?
- Always fullchain.pem. In nginx the ssl_certificate directive must point at a file containing the leaf certificate followed by the intermediates, which is exactly what certbot writes to fullchain.pem. cert.pem contains only the leaf and using it is the single most common cause of this error. The confusion comes from Apache, which historically used a separate SSLCertificateChainFile directive for the intermediates.
- Is it safe to set NODE_TLS_REJECT_UNAUTHORIZED=0 to get past a certificate error?
- No. That environment variable disables certificate verification for every outbound TLS connection made by the Node process, including to your database, your payment provider and your identity provider, so none of them are authenticated any more. If you genuinely need to trust a private or corporate certificate authority, point NODE_EXTRA_CA_CERTS at that CA instead, which adds one specific trusted authority rather than removing verification altogether.