CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate is not corruption, and verify=False is not a fix
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate means no path to a trusted root. The five real causes, and why verify=False is not a fix.
11 min read0 views
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate is the most misread error message in software. It is not corruption, not a library bug, and not a thing to be silenced. It is an authentication control reporting, accurately, that it could not establish who it was talking to.
The top-voted answers do not say that. They say verify=False, NODE_TLS_REJECT_UNAUTHORIZED=0, curl -k. Every one makes the message go away by turning off the check that produced it — the same move as torch.load(weights_only=False) in the ML ecosystem: taking a control that is doing its job and switching it off because its output is inconvenient. I wrote about that version of the mistake here. Same thesis, different ecosystem, committed far more often.
What the verifier is actually doing
A server presents a leaf certificate and, usually, some intermediates. Your client has to get from that leaf to a certificate it already trusts, its trust anchor, by walking a chain: the leaf's issuer name points at an intermediate, that intermediate's issuer points at another, until one is signed by a root in your local trust store. Each link is checked against the parent's public key.
The chain is not something the server hands you. It is something your verifier builds, from the certificates the server sent plus the certificates it already holds. That distinction is the whole post.
So read the error literally. "Unable to get local issuer certificate" means: I found a certificate claiming issuer X, and I do not have X. Not "X is untrustworthy" — different error. Not "the signature is wrong" — also different. Chain building stopped, because the next certificate up was neither in my bundle nor in what the server sent.
The rest of the family matters, because the number is the diagnosis:
| Error | Text | Meaning |
|---|---|---|
| 20 | unable to get local issuer certificate | Chain building stopped; an issuer is missing. |
| 21 | unable to verify the first certificate | Same cause, reported about the leaf. |
| 10 | certificate has expired | A cert is outside its validity window. |
| 9 | certificate is not yet valid or the system clock is incorrect | Usually your clock. |
| 19 | self signed certificate in certificate chain | Anchor present, not trusted. |
Python surfaces error 20 as ssl.SSLCertVerificationError, added in 3.7, carrying verify_code and verify_message holding exactly that number and text. Firefox spells the same condition SEC_ERROR_UNKNOWN_ISSUER. Different words, one condition.
The shortest proof that this is about the verifier's view, not the server: point Python at a healthy, publicly trusted site and hand it an empty trust store.
: > /tmp/empty-bundle.pem
SSL_CERT_FILE=/tmp/empty-bundle.pem python3 -c \
"import urllib.request; urllib.request.urlopen('https://www.google.com')"
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1082)>
Google's TLS is fine. Nothing was intercepted. The bundle was empty, so no chain could be built. That is the entire mechanism.
Why it works in curl but not in Python
OpenSSL will not fetch a missing intermediate. Certificates carry an Authority Information Access extension with a CA Issuers URI pointing at exactly the certificate you lack, and OpenSSL ignores it. Windows CryptoAPI and macOS's Security framework do fetch it; CPython has had an open issue about AIA chasing since 2013.
I hit this writing this post. On macOS, where curl links against the system verifier, a host with a deliberately incomplete chain returned 200; OpenSSL against that host gave verify error:num=20:unable to get local issuer certificate. Both were right about their own trust store. If curl works and your app does not, the server is missing an intermediate and something else is papering over it.
The fix everyone copies
All three copy-paste fixes are documented by their own maintainers in blunt terms. requests, on verify=False:
Note that when
verifyis set toFalse, requests will accept any TLS certificate presented by the server, and will ignore hostname mismatches and/or expired certificates, which will make your application vulnerable to man-in-the-middle (MitM) attacks.
Node, on NODE_TLS_REJECT_UNAUTHORIZED:
If
valueequals'0', certificate validation is disabled for TLS connections. This makes TLS, and HTTPS by extension, insecure. The use of this environment variable is strongly discouraged.
And curl's manual on -k, --insecure: "WARNING: using this option makes the transfer insecure."
None are hedged. All describe the same thing: you kept the encryption and threw away the authentication. Those are separate properties, and the discarded one decides whether the tunnel terminates at the server you meant or somebody else.
The structural point generalises past TLS. verify=False does not fix a trust problem. It deletes the trust requirement, so the problem becomes unrepresentable rather than solved. Identical shape to weights_only=False: both take a check that returned an unwelcome answer and remove the check. Silencing a control is not fixing trust. The system afterwards is not working — it is unable to tell you whether it is working.
Note how sticky these are. NODE_TLS_REJECT_UNAUTHORIZED=0 is process-wide, not per-request: it disables verification for every TLS connection that process makes for its entire life. It survives into Docker images, CI configs and Kubernetes manifests, which is where it is usually found years later.
What an attacker gets the moment verification is off
This is the part the StackOverflow answers never state.
Certificate verification is the only thing binding the encrypted channel to the identity of the endpoint. Remove it and your client completes a handshake with whatever is at the other end of the socket, accepting any certificate it is handed: self-signed, expired, issued for a different hostname, generated thirty seconds ago by anyone on the path.
Anyone on the path means the coffee shop network, a compromised router, whoever controls DNS or DHCP on that segment, a neighbouring workload in the same VPC, a malicious dependency that sets a proxy variable. No cryptography needs breaking. They terminate your connection, present a worthless certificate your client now accepts, and open their own connection onward. Both halves are encrypted. Neither is authenticated.
Through that position flows everything the client sends: the Authorization header, the API key, the session cookie, the database password, the OAuth token being refreshed. And because the attacker controls the responses, they can alter what comes back — the package you are installing, the config you are fetching. The confidentiality loss is bad; the integrity loss is frequently worse, because it turns a network position into code execution on your host.
Diagnosing the real cause
Five commands, against the host that is failing.
HOST=api.internal.example.com
# 1. What chain is the server sending, and what does the verifier make of it?
echo | openssl s_client -connect "$HOST":443 -servername "$HOST" 2>/dev/null \
| sed -n '/Certificate chain/,/^---/p'
echo | openssl s_client -connect "$HOST":443 -servername "$HOST" 2>&1 \
| grep -E 'verify error|Verify return code'
# 2. Which bundle is each runtime reading? These differ, and that is often the bug.
python3 -c "import ssl; print(ssl.get_default_verify_paths())"
python3 -m certifi
python3 -c "from requests.utils import DEFAULT_CA_BUNDLE_PATH; print(DEFAULT_CA_BUNDLE_PATH)"
# 3. Rule the clock in or out.
date -u
echo | openssl s_client -connect "$HOST":443 -servername "$HOST" 2>/dev/null \
| openssl x509 -noout -dates
# 4. Who is at the top of the chain sent? Interception shows up here.
echo | openssl s_client -connect "$HOST":443 -servername "$HOST" 2>/dev/null \
| grep '^ i:' | tail -1
# 5. Verify by hand against a specific CA file. This answers the question.
openssl verify -CAfile /path/to/expected-ca.pem -untrusted intermediate.pem leaf.pem
Step 1 on a healthy host shows several certificates and Verify return code: 0 (ok). On a server not sending its intermediate it shows exactly one, then:
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)
A chain of length one, for a certificate from a public CA, is the diagnosis. Public CAs do not sign leaf certificates with their roots, so one certificate means an intermediate is being withheld.
Now the most useful thing here. openssl verify prints the depth at which chain building stopped, and the depth tells you which cause you have. A throwaway three-level CA makes it clear:
# root trusted, intermediate withheld
error 20 at 0 depth lookup: unable to get local issuer certificate
# intermediate supplied as well
leaf.pem: OK
# full chain sent, but the root is not in the bundle
error 20 at 1 depth lookup: unable to get local issuer certificate
Same error number, same text, two different causes and two different fixes:
- Error 20 at depth 0 — the server is not sending its intermediate. Fix the server.
- Error 20 at a deeper depth — the chain is complete, but its anchor is not in your trust store. Fix the trust store.
That one number saves the afternoon people usually spend typing verify=False.
Five causes, five fixes
| Cause | Signature | Correct fix |
|---|---|---|
| a. Server not serving its intermediate | Chain length 1; error 20 at depth 0; works in browsers | Fix the server's chain file: leaf + intermediate(s). Do not patch clients. |
| b. TLS-inspecting corporate proxy | Top-of-chain issuer is your employer or a security vendor | Add the proxy's CA to the runtime's trust store. |
| c. Stale or wrong CA bundle | Error 20 at depth ≥ 1; old certifi; unexpected bundle path | Upgrade certifi; point the runtime at the right bundle. |
| d. Private internal CA | Top-of-chain issuer is your own CA; chain complete | Distribute your root as a trust anchor. Configuration, not an error. |
| e. Clock skew | Error 9 or 10, not 20 | Fix NTP. |
On (e), a correction to a claim you will see repeated: with OpenSSL, clock skew does not produce this error. I tested both directions and an expired trust anchor; every case reported error 10 (certificate has expired) or error 9 (certificate is not yet valid or the system clock is incorrect), never error 20. Clock skew is a real cause of verification failure and belongs on the checklist, but if you have error 20, step 3 rules it out in one command. This is exactly why you read the error number rather than pattern-matching prose.
For (b), (c) and (d) the mechanism is the same — put the right anchor in front of the right runtime — and the trap is that every runtime reads a different bundle:
export SSL_CERT_FILE=/path/to/bundle.pem # Python ssl (also SSL_CERT_DIR)
export REQUESTS_CA_BUNDLE=/path/to/bundle.pem # requests; falls back to CURL_CA_BUNDLE
export NODE_EXTRA_CA_CERTS=/path/to/root.pem # Node; extends built-in roots
curl --cacert /path/to/bundle.pem https://host/
Three details that cause real outages. Node's bundled CA store is a snapshot of the Mozilla store frozen at release time, so it drifts as roots change; --use-system-ca (added in v23.8.0, extended beyond Windows and macOS in v23.9.0) makes Node read the platform store instead. NODE_EXTRA_CA_CERTS is read only at process launch, so setting it via process.env at runtime does nothing, and it is ignored when Node runs setuid root. And certifi refuses to be a general trust store — its documentation states it "does not support any addition/removal or other modification of the CA trust store content" — so add a corporate root as a separate bundle you point at, never an edit to cacert.pem that the next upgrade discards. The current certifi release is 2026.7.22; if yours is much older, upgrade first.
When pinning is the right answer
Pinning means refusing to trust the CA system for a connection and accepting only one specific key. It is a real control and usually the wrong one.
Pin when all three hold: you control both ends, the connection is high-value and long-lived, and you have an operational process for rotation. Mobile apps talking to their own backend. Agents phoning home. Payment or signing infrastructure where a mis-issued certificate from any public root is unacceptable. Pin the public key, not the certificate, so renewal with the same key does not break clients.
Do not pin because you read that pinning is more secure. Against the failure modes most services face it buys little and costs a self-inflicted outage the first time a certificate rotates, which with short-lived automated certificates is routine. A pin without a rotation runbook is a scheduled outage with an unknown date. And it does not solve this problem: if you cannot build a chain today, pinning is a different project.
If the goal is narrowing trust rather than pinning one key, the better-proportioned move is a restricted trust store: verify against a bundle holding only the CAs that should ever sign for this endpoint. Renewals keep working.
Take this away
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate is a successful test with a result you did not want. It makes no claim about the server; it makes a claim about what your verifier can prove. The honest response is to supply the missing certificate, not to stop asking.
Two lines you should be able to justify in review, in any language, for the rest of your career:
verify=False
NODE_TLS_REJECT_UNAUTHORIZED=0
Neither is a fix. Both convert "I cannot authenticate this server" into "I will not try", and the difference is invisible in your logs, your tests and production, right up until somebody is on the path.
Sources
Was this useful?
Comments
Loading comments…