Skip to main content
All articles

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.

13 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. It is 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. That version of the mistake got its own post 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 the verifier found a certificate claiming issuer X and does not hold X. It does not mean "X is untrustworthy", which is a different error. It does not mean "the signature is wrong", which is different again. Chain building stopped, because the next certificate up was neither in the local bundle nor in what the server sent.

The rest of the family matters, because the number is the diagnosis:

ErrorTextMeaning
20unable to get local issuer certificateChain building stopped; an issuer is missing.
21unable to verify the first certificateSame cause, reported about the leaf.
10certificate has expiredA cert is outside its validity window.
9certificate is not yet valid or the system clock is incorrectUsually your clock.
19self signed certificate in certificate chainAnchor 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.

The split is reproducible in two commands. On macOS, where curl links against the system verifier, a host with a deliberately incomplete chain returns 200, while OpenSSL against that same host reports verify error:num=20:unable to get local issuer certificate:

curl -o /dev/null -s -w '%{http_code}\n' https://incomplete-chain.badssl.com/
echo | openssl s_client -connect incomplete-chain.badssl.com:443 \
  -servername incomplete-chain.badssl.com 2>&1 | grep -m1 'verify error'
200
verify error:num=20:unable to get local issuer certificate

Both answers are correct about their own trust store, and the server is identical in each case. So when curl works and your application does not, that gap is the finding: 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 verify is set to False, 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 value equals '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.

Confirm it yourself

None of this needs a lab. openssl already ships with macOS and every mainstream Linux distribution, and badssl.com keeps four public hosts online, each broken in exactly one way. Four commands, four failures:

for h in incomplete-chain untrusted-root self-signed expired; do
  printf '%s: ' "$h"
  echo | openssl s_client -connect "$h".badssl.com:443 -servername "$h".badssl.com 2>&1 \
    | grep -m1 'verify error'
done
incomplete-chain: verify error:num=20:unable to get local issuer certificate
untrusted-root: verify error:num=19:self-signed certificate in certificate chain
self-signed: verify error:num=18:self-signed certificate
expired: verify error:num=10:certificate has expired

Four hosts, four numbers, and the number is the whole diagnosis.

incomplete-chain is the production misconfiguration itself: a publicly trusted leaf with its intermediate withheld, reported as 20 and then, one line further down, 21. untrusted-root is what a TLS-inspecting corporate proxy and a private internal CA both look like from the client side: the chain is complete and internally consistent, its anchor is simply not one this machine trusts, and the number moves from 20 to 19 to say precisely that. self-signed collapses the chain to a single certificate that is its own issuer, which is 18. Three failures that look identical in a stack trace, three different fixes, and nothing distinguishes them except the integer.

expired is the one that matters most here, because it settles the clock question before anyone thinks to ask it. A certificate past its validity window reports 10, on a host anybody can reach. -attime evaluates the chain at a timestamp of your choosing, which shows the opposite direction against a host that is healthy today:

echo | openssl s_client -connect badssl.com:443 -servername badssl.com -attime 1420070400 2>&1 \
  | grep -m1 'verify error'
verify error:num=9:certificate is not yet valid or the system clock is incorrect

A verifier judging a current certificate at a 2015 timestamp reports 9. A certificate past its notAfter reports 10. Neither reports 20.

Five causes, five fixes

CauseSignatureCorrect fix
a. Server not serving its intermediateChain length 1; error 20 at depth 0; works in browsersFix the server's chain file: leaf + intermediate(s). Do not patch clients.
b. TLS-inspecting corporate proxyTop-of-chain issuer is your employer or a security vendorAdd the proxy's CA to the runtime's trust store.
c. Stale or wrong CA bundleError 20 at depth ≥ 1; old certifi; unexpected bundle pathUpgrade certifi; point the runtime at the right bundle.
d. Private internal CATop-of-chain issuer is your own CA; chain completeDistribute your root as a trust anchor. Configuration, not an error.
e. Clock skewError 9 or 10, not 20Fix NTP.

On (e), a correction to a claim you will see repeated: with OpenSSL, clock skew does not produce this error. Skew in either direction reports error 10 (certificate has expired) or error 9 (certificate is not yet valid or the system clock is incorrect), and so does an expired trust anchor. None of them reports error 20, which the previous section demonstrates in two commands against public hosts. 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?

Share

Tags

  • cryptography
  • python
  • secure code review
  • web security
  • detection engineering

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Web Application Testing

Manual, business-logic-aware testing of the applications your customers touch.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.