CoreMesh

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 in Python: The Real Fix

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:

text
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:

text
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.

python
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.

python
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:

bash
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.

bash
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:

python
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:

bash
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:

bash
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:

python
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:

python
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.

python
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:

python
# 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=False in a helper function becomes a Session default, 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 certifi overwrites 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 read REQUESTS_CA_BUNDLE, and that "an SSL: CERTIFICATE_VERIFY_FAILED is thrown" as a result. Use Session.merge_environment_settings() as the docs show, or pass verify= to send() explicitly.
  • Setting verify on the session, then overriding it. requests.get(url, verify=True) inside a session with s.verify = "/path" wins at call time. Check the per-call argument.
  • Pointing verify at a directory. Directories need c_rehash processing. A folder full of .pem files 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 certifi upgraded the system Python while your virtual environment still has the old bundle. Compare sys.executable in the traceback's environment with the one you ran pip from.
  • Assuming the error is on your side. certificate has expired or 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.

  1. Read the reason after certificate verify failed:. Expired or hostname mismatch means the server is wrong. Stop there.
  2. Print ssl.OPENSSL_VERSION, REQUESTS_CA_BUNDLE and DEFAULT_CA_BUNDLE_PATH with the failing interpreter.
  3. python -m pip install --upgrade certifi in that same environment. Rerun.
  4. Still failing with self-signed certificate in certificate chain? Get the proxy's root CA as PEM.
  5. cat the default bundle and the corporate PEM into ~/certs/ca-bundle.pem.
  6. export REQUESTS_CA_BUNDLE="$HOME/certs/ca-bundle.pem", or pass verify="/path", or set Session.verify.
  7. For urllib: create_default_context() plus load_verify_locations() and pass context=.
  8. Confirm by running with and without the variable. Make it permanent where the process launches.
  9. verify=False only 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.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

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

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS