SSL Certificate Verify Failed in Python: The Real Fix
Fix SSL: CERTIFICATE_VERIFY_FAILED in Python requests, urllib and pip: find what Python trusts, add your corporate CA to a bundle, skip verify=False.

SSL: CERTIFICATE_VERIFY_FAILED means Python opened a TLS connection, the server handed over a certificate, and the OpenSSL library inside Python couldn't trace that certificate back to a root it trusts. The fix is almost never to turn verification off. It's to get the right root CA into the bundle Python actually reads.
In practice there are three cases. The CA bundle is stale or missing, so a normal public site fails: upgrade certifi. Something between you and the internet, usually a corporate proxy doing TLS inspection, re-signs every connection with its own CA: append that CA to a custom bundle and point REQUESTS_CA_BUNDLE at it. Or the server really is misconfigured, and the error is telling the truth. Below is how to tell which one you have, and how to confirm each fix worked.
What the error looks like and why it happens#
With requests the traceback ends like this:
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/items (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)')))
With urllib from the standard library:
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:1000)>
The part that matters is the text after certificate verify failed:. That's OpenSSL's reason code, and it tells you which fix to reach for.
| Reason text | What it means | Likely fix |
|---|---|---|
unable to get local issuer certificate |
The CA (or intermediate) that signed the server's certificate isn't in the bundle Python used | Upgrade the bundle, or add the missing CA |
self-signed certificate in certificate chain |
The chain ends at a root nobody in the bundle knows. Classic sign of a corporate proxy intercepting HTTPS | Append the proxy's root CA to a custom bundle |
certificate has expired |
The server's certificate is past its end date | Fix the server. Don't touch the client |
Why it fails at all: the Python ssl module docs describe PROTOCOL_TLS_CLIENT as enabling CERT_REQUIRED and check_hostname by default. In CERT_REQUIRED mode an SSLError is raised if the peer's certificate fails validation. requests builds on that and, per the requests advanced usage docs, verifies certificates "just like a web browser" but uses the CA list from the certifi package rather than whatever your browser trusts. That's the trap: your browser opening the site proves nothing about what Python trusts.
Here's a tiny helper that maps the reason text to the cause. Paste your own exception message into the list to see where it lands.
HINTS = {
"unable to get local issuer certificate":
"Issuer CA is missing from the bundle Python used. Upgrade certifi or add the CA.",
"self-signed certificate in certificate chain":
"Something re-signed the connection, usually a corporate proxy. Add its root CA to your bundle.",
"certificate has expired":
"The server's certificate is expired. Fix the server, not the client.",
}
def explain(message):
for needle, hint in HINTS.items():
if needle in message:
return hint
return "Unrecognized reason. Print the full exception and compare it to the table."
errors = [
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)",
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:1000)",
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1000)",
]
for err in errors:
reason = err.split("certificate verify failed: ")[1].split(" (_ssl")[0]
print(f"{reason}\n -> {explain(err)}\n")
Find out what your Python actually trusts#
Before changing anything, look at the interpreter that's failing. Run this with the same python your script uses, not whatever python3 happens to be on your PATH. The OpenSSL build and the environment variables are what matter.
import os
import ssl
ctx = ssl.create_default_context()
print("OpenSSL:", ssl.OPENSSL_VERSION)
print("client protocol:", ctx.protocol == ssl.PROTOCOL_TLS_CLIENT)
print("verify_mode:", ctx.verify_mode.name)
print("check_hostname:", ctx.check_hostname)
for var in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
print(f"{var}:", os.environ.get(var, "(not set)"))
If you use requests, also print the bundle it will read by default. The requests docs point to this exact attribute:
python -c "from requests.utils import DEFAULT_CA_BUNDLE_PATH; print(DEFAULT_CA_BUNDLE_PATH)"
That path is the file every HTTPS request is checked against unless verify= or REQUESTS_CA_BUNDLE overrides it. Hold onto it; the next two fixes depend on it.
Fix 1: upgrade certifi for stale public roots#
If a public site fails with unable to get local issuer certificate and nobody is intercepting your traffic, the bundle is probably old. The requests docs say requests "uses certificates from the package certifi" precisely so you can refresh trusted roots without changing requests itself, and they recommend upgrading certifi frequently.
python -m pip install --upgrade certifi
Use the same interpreter that's failing. An old certifi from a different interpreter doesn't help it. If pip refuses to touch a system Python, the fix for that is covered in the externally-managed-environment pip error and usually means "use a virtual environment".
Confirm it worked: rerun the failing request. If the error changes from unable to get local issuer certificate to self-signed certificate in certificate chain, you've moved to the proxy case below. If it stays the same, print DEFAULT_CA_BUNDLE_PATH again and make sure you upgraded certifi in the environment that owns that path. The certifi project page on PyPI is the package you're installing.
Fix 2: behind a corporate proxy, build a custom CA bundle#
This is the most common cause on managed Windows and macOS laptops. The proxy terminates TLS, inspects traffic, and re-encrypts it with a certificate signed by the company's own root CA. Your browser trusts that root because IT pushed it into the OS. requests doesn't, because certifi doesn't ship your employer's CA.
The requests docs are direct about this: "using a proxy for https connections typically requires your local machine to trust the proxy's root certificate." The fix has three steps.
Step 1: get the root CA as a PEM file. Ask IT for it, or export it from your OS certificate store in PEM (Base64) format. You want the root, not the per-site leaf certificate.
Step 2: append it to a copy of the default bundle. Don't replace the bundle with only the corporate CA; then every public site stops working. A PEM bundle is just certificates concatenated, which this demo shows with placeholder content:
def count_certs(pem_text):
return pem_text.count("-----BEGIN CERTIFICATE-----")
public_bundle = """-----BEGIN CERTIFICATE-----
(public root 1, base64 body)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(public root 2, base64 body)
-----END CERTIFICATE-----"""
corporate_ca = """-----BEGIN CERTIFICATE-----
(your proxy's root CA, base64 body)
-----END CERTIFICATE-----
"""
combined = public_bundle.rstrip("\n") + "\n" + corporate_ca
print("public roots: ", count_certs(public_bundle))
print("corporate roots:", count_certs(corporate_ca))
print("combined bundle:", count_certs(combined))
print("newline between blocks:", "-----END CERTIFICATE-----\n-----BEGIN" in combined)
The real version is one shell line. It copies the default bundle and appends your CA into a file outside the certifi package, so a later certifi upgrade can't wipe it:
mkdir -p ~/certs
cat "$(python -c 'from requests.utils import DEFAULT_CA_BUNDLE_PATH; print(DEFAULT_CA_BUNDLE_PATH)')" \
~/Downloads/corp-root-ca.pem > ~/certs/ca-bundle.pem
Step 3: tell requests to use it. The cleanest way is the environment variable, which the requests docs document for exactly this situation:
export REQUESTS_CA_BUNDLE="$HOME/certs/ca-bundle.pem"
If REQUESTS_CA_BUNDLE isn't set, requests falls back to CURL_CA_BUNDLE, so setting that one instead also works and covers curl. If you'd rather keep it in code, pass the path to verify:
import requests
r = requests.get("https://api.example.com/v1/items", verify="/home/you/certs/ca-bundle.pem")
print(r.status_code)
Or set it once on a session:
import requests
s = requests.Session()
s.verify = "/home/you/certs/ca-bundle.pem"
r = s.get("https://api.example.com/v1/items")
One detail from the docs: if verify points at a directory rather than a file, that directory must have been processed with OpenSSL's c_rehash tool. A plain folder of .pem files won't be read. Stick with a single bundle file unless you have a reason not to.
Confirm it worked: with the variable exported, rerun the script that failed. Then open a new terminal, don't export anything, and rerun it. It should fail again. That proves the variable is the fix and not something else that changed. Make the export permanent in your shell profile, or in your IDE's run configuration, since an IDE launched from the dock doesn't read your terminal's exports.
This applies to any library built on requests or on the standard library ssl module. If you're calling an API from Python, as in these runnable OpenAI API examples in Python, the same bundle and the same variable apply.
Fix 3: urlopen error with urllib and the standard library#
urllib.request doesn't read REQUESTS_CA_BUNDLE. It uses an SSLContext, and the fix is to add your CA to that context rather than to weaken it. create_default_context() gives you the secure client defaults from the ssl docs, and load_verify_locations() is the documented way to pass a set of CA certificates.
import ssl
import urllib.request
ctx = ssl.create_default_context()
ctx.load_verify_locations("/home/you/certs/corp-root-ca.pem")
with urllib.request.urlopen("https://api.example.com/v1/items", context=ctx) as resp:
print(resp.status)
Note that here you only load the corporate CA. create_default_context() already loads the system defaults, and load_verify_locations() adds to them. See the urllib.request documentation for the context argument.
Two related knobs from the ssl docs, for when IT hands you something unusual. SSLContext.load_default_certs() with Purpose.SERVER_AUTH reloads the default roots for a client context if you built the context by hand. And VERIFY_X509_PARTIAL_CHAIN (Python 3.10 and later) makes OpenSSL treat an intermediate CA in the trust store as a trust anchor, which is what you need if you were given only an intermediate certificate and not the root.
Windows, macOS and pip#
The mechanics are identical on every OS, but the ways to get confused differ.
Windows. Corporate TLS inspection is most common here. Set REQUESTS_CA_BUNDLE in your user environment variables, not just in one PowerShell window, so Jupyter, VS Code and scheduled tasks all see it. Use a path without spaces if you can, and point at a file, not a directory.
macOS. The usual problem is several Pythons: one from python.org, one from Homebrew, one inside a project's virtual environment. Each has its own certifi and its own DEFAULT_CA_BUNDLE_PATH. Run the inspection snippet above with the exact interpreter from your traceback before changing anything.
pip. pip install hitting CERTIFICATE_VERIFY_FAILED has the same cause: the proxy's root isn't in the trust store pip reads. The fix is the same PEM file. pip has its own configuration for pointing at a certificate file, documented in pip's user guide, and it doesn't pick up verify= from your code because there's no code involved. Avoid the temptation to mark the index as a trusted host; that disables verification for it, which is the same risk as verify=False.
Why verify=False is not the fix#
Every top search result suggests it, and it does make the error go away:
# Don't ship this.
requests.get("https://api.example.com/v1/items", verify=False)
The requests docs spell out what you just did: with verify=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." At the ssl level this is CERT_NONE, where the docs say "just about any cert is accepted" and validation errors "are ignored and do not abort the TLS/SSL handshake."
The practical problems, beyond the obvious one:
- It hides the real diagnosis. You never learn whether a proxy, a stale bundle, or a broken server caused it, so the next library or tool fails the same way.
- It spreads. A
verify=Falsein a helper function becomes aSessiondefault, becomes a copy-pasted snippet in production that talks to a payment API. - It doesn't help the standard library, pip, or anything else that isn't
requests.
The docs do allow it "during local development or testing". If that's genuinely your situation, keep it on one call, never on a Session, never behind an environment flag that could reach production, and leave a comment with the ticket for the real fix.
Common mistakes and gotchas#
- Replacing the bundle instead of appending. If
verify=points at a file containing only the corporate CA, public sites that the proxy doesn't intercept start failing. Always concatenate onto a copy of the default bundle. - Editing the bundle inside the certifi package. It works until the next
pip install --upgrade certifioverwrites it. Keep your bundle in your own directory. - Prepared requests ignore the environment. The requests docs note that
Session.send()with a prepared request doesn't readREQUESTS_CA_BUNDLE, and that "an SSL: CERTIFICATE_VERIFY_FAILED is thrown" as a result. UseSession.merge_environment_settings()as the docs show, or passverify=tosend()explicitly. - Setting
verifyon the session, then overriding it.requests.get(url, verify=True)inside a session withs.verify = "/path"wins at call time. Check the per-call argument. - Pointing
verifyat a directory. Directories needc_rehashprocessing. A folder full of.pemfiles silently verifies nothing new. - Exporting the variable in one shell only. Jupyter kernels, IDE run buttons and cron jobs don't inherit your terminal. Put it where the process actually starts.
- Wrong interpreter.
python3 -m pip install --upgrade certifiupgraded the system Python while your virtual environment still has the old bundle. Comparesys.executablein the traceback's environment with the one you ran pip from. - Assuming the error is on your side.
certificate has expiredor a hostname mismatch is the server's problem. Report it; don't paper over it.
Quick reference#
Work top to bottom and stop at the first step that fixes it.
- Read the reason after
certificate verify failed:. Expired or hostname mismatch means the server is wrong. Stop there. - Print
ssl.OPENSSL_VERSION,REQUESTS_CA_BUNDLEandDEFAULT_CA_BUNDLE_PATHwith the failing interpreter. python -m pip install --upgrade certifiin that same environment. Rerun.- Still failing with
self-signed certificate in certificate chain? Get the proxy's root CA as PEM. catthe default bundle and the corporate PEM into~/certs/ca-bundle.pem.export REQUESTS_CA_BUNDLE="$HOME/certs/ca-bundle.pem", or passverify="/path", or setSession.verify.- For
urllib:create_default_context()plusload_verify_locations()and passcontext=. - Confirm by running with and without the variable. Make it permanent where the process launches.
verify=Falseonly on a single call, only in local testing, with a comment pointing at the real fix.
Quick answers
How do I fix SSL: CERTIFICATE_VERIFY_FAILED in Python requests?
Check the reason text after the error. For a public site, upgrade certifi in the same environment. Behind a corporate proxy, append the proxy's root CA to a copy of the default bundle and point REQUESTS_CA_BUNDLE or the verify argument at that file.
Can I just ignore the certificate error with verify=False?
You can, but the requests docs warn it accepts any certificate, ignores hostname mismatches and expiry, and exposes you to man-in-the-middle attacks. Reserve it for a single call in local testing and fix the CA bundle for everything else.
Why does pip fail with CERTIFICATE_VERIFY_FAILED on my work laptop?
The proxy's root CA isn't in the trust store pip reads. Export that CA as a PEM file and configure pip's own certificate option to use it, rather than marking the package index as a trusted host.
Is this error specific to Python 3.12 on Windows or macOS?
No. The error isn't tied to one Python version. Run the inspection snippet with the new interpreter and apply the same bundle fix.
How do I fix urlopen error CERTIFICATE_VERIFY_FAILED without requests?
Build a context with ssl.create_default_context(), call load_verify_locations() with your CA file, and pass it as the context argument to urllib.request.urlopen(). The standard library doesn't read REQUESTS_CA_BUNDLE.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note