Home › Python › Requests SSLError: Fix Verify-Failed Behind Proxies
Intermediate 5 min · September 23, 2026

Requests SSLError: Fix Verify-Failed Behind Proxies

Point Requests at the right CA bundle and the SSLError clears: how verify, certifi, and proxy chains work, plus safe fixes that beat verify=False..

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 13 min
  • ✓Basic Python and pip installed locally
  • ✓You've made an HTTPS request with Requests before
  • ✓Access to a terminal with openssl available
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • CERTIFICATE_VERIFY_FAILED means Requests couldn't chain the server's cert to a root in its CA bundle — read the message detail before acting
  • Corporate MITM proxies re-sign traffic, so install the proxy's root CA and export REQUESTS_CA_BUNDLE to a bundle containing it
  • Match the message: expired means renew the server cert, self-signed means add the private root, missing issuer means repair the chain
  • Never ship verify=False since it disables all server authentication and exposes tokens to interception
✦ Definition~90s read
What is Python Requests SSLError Fix?

Requests is Python's most-used HTTP client, and verify is the parameter that controls TLS server authentication on every HTTPS call. By default verify=True, which means Requests (through urllib3 and OpenSSL) checks that the server's certificate chains to a trusted root and that the hostname matches.

★
Think of HTTPS like checking IDs at a concert.

The trusted roots come from a CA bundle — a PEM file full of public root certificates — and Requests resolves that bundle in a fixed order: the verify argument if you passed a path, then the REQUESTS_CA_BUNDLE environment variable, then CURL_CA_BUNDLE, then certifi's bundled file.

certifi is simply a regularly updated snapshot of Mozilla's trusted-root list, packaged so Python apps get consistent roots on every OS. That consistency is its strength and its blind spot: it knows every public root but no private ones — your company's proxy root, your internal CA, your dev self-signed cert.

When the server's chain ends at any root outside that set, OpenSSL aborts with CERTIFICATE_VERIFY_FAILED and Requests wraps it in an SSLError.

That wrapper matters for debugging. requests.exceptions.SSLError is the transport-level complaint; the useful detail sits one level down in the message (expired, self-signed, unable to get local issuer) and, for stdlib ssl errors, in verify_code. Once you read the bundle resolution order and the message detail together, every SSLError reduces to a short question: which root is missing from which bundle, or which link of the chain is broken?

Plain-English First

Think of HTTPS like checking IDs at a concert. Your browser carries a big book of trusted ID issuers. Python's Requests carries its own smaller book (called certifi). If the server's ID was signed by someone not in Python's book — an expired pass, a homemade badge, or a corporate security gate that re-stamps every badge — the bouncer says no. The fix isn't firing the bouncer (verify=False); it's giving him the right book of trusted stamps.

Your deploy log shows one line and your whole pipeline stops: requests.exceptions.SSLError: CERTIFICATE_VERIFY_FAILED. The URL works in your browser. curl passes from the same laptop. Yet Python refuses to connect, and the traceback offers no hint about which certificate it disliked or why. If you've hit this on office Wi-Fi the week before launch, you know the frustration.

The confusion comes from how many trust stores one laptop holds. Browsers use the OS store, curl often uses another, and Python's Requests uses certifi's bundled roots unless you tell it otherwise. A corporate proxy that re-signs traffic, an expired leaf, or a server that forgot its intermediate cert each breaks a different link of the same chain — but all surface as the identical SSLError.

This guide gives you a repeatable way out. You'll learn what the verify-failed message really asserts, how to classify expired versus self-signed versus incomplete-chain failures in seconds, where certifi and REQUESTS_CA_BUNDLE fit, why verify=False is never the answer, and what makes macOS special. By the end you'll diagnose any Requests SSLError in minutes instead of stabbing at reinstalls.

What CERTIFICATE_VERIFY_FAILED Actually Means

When Requests raises CERTIFICATE_VERIFY_FAILED, it's reporting one specific failed assertion: OpenSSL could not build an unbroken signature path from the server's leaf certificate up to a root certificate in the bundle it was given. The handshake itself worked — bytes flowed, the server presented its cert. What failed was the trust decision afterward. That distinction matters because it tells you the network is fine and the cryptography is fine; only the trust inputs are wrong.

Those inputs are small: the chain the server sent, the hostname you requested, the current time, and the set of roots you trust. Requests assembles the trust set from the verify argument first, then REQUESTS_CA_BUNDLE, then CURL_CA_BUNDLE, then certifi's bundled PEM. OpenSSL walks the chain link by link, checking signatures, dates, and the hostname, and the first broken link becomes the message you see.

So your first move is evidence, not action. Run the trust-store snippet and note exactly which bundle your interpreter resolves. Then reproduce with openssl s_client and read its Verify return code. Only when you can name the broken link — wrong bundle, expired leaf, unknown root, missing intermediate — should you change anything. That discipline turns a scary TLS error into a ten-minute lookup.

check_trust_store.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
import os
import ssl

import certifi

print("certifi bundle:", certifi.where())
print("openssl defaults:", ssl.get_default_verify_paths())
for var in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE"):
    print(var, "=", os.environ.get(var, "(not set)"))
ctx = ssl.create_default_context()
print("default verify mode:", ctx.verify_mode)
print("check_hostname:", ctx.check_hostname)
📊 Production Insight
A deploy failed with SSLError minutes before launch. The engineer ran the trust-store check and saw REQUESTS_CA_BUNDLE pointing at a deleted file from a rotated secrets mount.
Symptom: every HTTPS call failed while curl passed.
Rule: print the resolved bundle path inside the failing process before you touch certs.
🎯 Key Takeaway
The handshake worked but the trust decision failed — name the broken link with evidence before changing anything.

Expired vs Self-Signed vs Incomplete Chain

Three failures share one exception name, and mixing them up wastes hours. An expired leaf means the server's own dates lapsed — the chain is structurally fine but time has run out. A self-signed certificate means no trusted root signed it at all, so there's no path to build no matter how fresh the bundle is. An incomplete chain means the server forgot to send its intermediate certificates, so OpenSSL sees the leaf floating with a gap between it and a root it actually trusts.

Each has a signature. Expired names itself plainly and openssl shows notAfter in the past. Self-signed shows issuer identical to subject, and s_client reports self-signed certificate. Missing intermediates produce unable to get local issuer certificate or error codes 20/21, and -showcerts reveals only one certificate where two or three should appear.

The fixes don't transfer. Renewal is the server owner's job and no client bundle edit helps. Self-signed needs the private root installed on the client. A missing intermediate needs the server to send its full chain — though you can bandage it client-side with a bundle containing the intermediate. The classifier snippet trains your eye: paste the message, get the category, then apply only that category's fix.

classify_ssl_error.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SAMPLES = {
    "certificate has expired": "expired leaf",
    "self-signed certificate": "self-signed",
    "unable to get local issuer certificate": "incomplete chain",
}


def classify(message):
    low = message.lower()
    for marker, kind in SAMPLES.items():
        if marker in low:
            return kind
    return "unknown"


tests = [
    ("ssl.SSLCertVerificationError: certificate has expired", "expired leaf"),
    ("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate", "self-signed"),
    ("unable to get local issuer certificate", "incomplete chain"),
]
for msg, want in tests:
    got = classify(msg)
    assert got == want, (msg, got)
    print(f"{got:>16} <= {msg[:60]}")
print("classifier ok")
📊 Production Insight
An outage blamed on an expired cert was really a missing intermediate after a server migration dropped the chain file.
Symptom: CERTIFICATE_VERIFY_FAILED the morning after a routine web server move.
Rule: count the certs in s_client output — one cert where two should be means the chain file wasn't deployed.
🎯 Key Takeaway
Expired, self-signed, and missing-intermediate failures need different owners — classify first, then fix.

Corporate MITM Proxies: Why Office Wi-Fi Breaks Everything

Corporate proxies break TLS on purpose. To scan traffic, the proxy terminates your session, inspects the plaintext, then opens its own session to the real server — re-signing everything with a company root your laptop was given on hiring day. Browsers trust it because IT pushed the root into the OS store. Python doesn't, because Requests reads certifi's public bundle, which has never heard of your company's root.

The tell is environmental: failures follow the network, not the code. Office Wi-Fi and VPN fail while hotspots pass, and env shows HTTPS_PROXY set. The proxy isn't broken and the target server isn't broken — your client simply lacks one root certificate.

The durable fix has two halves. Get the corporate root CA (usually a .crt or .pem from IT or the OS store), append it to a copy of certifi's bundle, and export REQUESTS_CA_BUNDLE to that file wherever the app runs — laptop shells, CI environments, and container images. The snippet shows the resolution pattern: explicit bundle wins, environment provides it, and the session proves the path before any byte hits the wire. Do it once per environment and the whole class of failures vanishes. Log the resolved bundle path at startup so the next proxy rotation shows up as a one-line diff instead of another morning-long mystery.

corporate_proxy_fix.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os

import certifi
import requests

bundle = os.environ.get("REQUESTS_CA_BUNDLE", certifi.where())
s = requests.Session()
s.verify = bundle
print("session verify resolves to:", s.verify)
assert isinstance(s.verify, str) and s.verify.endswith(".pem")
req = requests.Request("GET", "https://internal.example.com/api/health")
prepped = s.prepare_request(req)
print("prepared:", prepped.method, prepped.url)
print("https proxy:", os.environ.get("HTTPS_PROXY", "(none)"))
print("ok - trust path resolved, no request sent")
📊 Production Insight
Every new hire lost a day to pip and Requests failures until IT baked the proxy root into the standard laptop image.
Symptom: identical repo passed for veterans but failed for new hires on the same Wi-Fi.
Rule: ship the root CA in the base image and document REQUESTS_CA_BUNDLE on day one.
🎯 Key Takeaway
Proxy failures follow the network, not the code — install the corporate root and export REQUESTS_CA_BUNDLE everywhere the app runs.

Why verify=False Is Never the Fix

When nothing else works, verify=False always does — and that's exactly what makes it dangerous. It doesn't fix trust; it deletes the trust question. Hostname checks go away, chain checks go away, expiry checks go away. Your code will happily accept a cert for the wrong host, signed by nobody, relayed by an attacker running a hotspot named after your office. Tokens, cookies, and payloads all flow through a channel you chose not to authenticate.

The snippet proves both halves against a real local TLS server with a self-signed cert. With verify=False the request succeeds but Python fires InsecureRequestWarning — the library itself telling you the channel is unauthenticated. With default verification the same request raises SSLError, which is the protection working as designed. Read those two outcomes together: the error you wanted to silence was the guard doing its job.

There's also a shelf-life trap. verify=False committed during a late-night debug becomes permanent because nothing forces a revisit — until a scanner flags it or an attacker exploits it. The safe replacement is narrow: verify="/path/to/internal-ca.pem" for the one host that needs it, or a merged bundle via REQUESTS_CA_BUNDLE. Add grep -rn verify=False to CI so the bypass can never ship quietly again.

verify_false_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
import warnings
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import urllib3
import requests


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"{\"ok\": true}"
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass


tmp = tempfile.mkdtemp()
key, crt = tmp + "/k.pem", tmp + "/c.pem"
subprocess.run([shutil.which("openssl"), "req", "-x509", "-newkey",
                "rsa:2048", "-keyout", key, "-out", crt, "-days", "1",
                "-nodes", "-subj", "/CN=127.0.0.1"],
               check=True, capture_output=True)
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(crt, key)
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"https://127.0.0.1:{port}/"
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    r = requests.get(url, verify=False, timeout=10)
    print("verify=False status:", r.status_code, r.text)
bad = [w for w in caught
       if issubclass(w.category, urllib3.exceptions.InsecureRequestWarning)]
print("InsecureRequestWarning fired:", bool(bad))
assert bad, "expected InsecureRequestWarning"
try:
    requests.get(url, timeout=10)
except requests.exceptions.SSLError as e:
    print("default verify still refuses self-signed:", str(e)[:80])
srv.shutdown()
srv.server_close()
⚠ verify=False Is a Vulnerability, Not a Workaround
Disabling verification removes hostname, chain, and expiry checks in one flag. Any device on the network path can then read or rewrite your API traffic, including auth tokens.
📊 Production Insight
A verify=False left in a payments client survived eleven months until a pen test flagged card traffic crossing an unauthenticated channel.
Symptom: no errors anywhere, which was precisely the problem — nothing could have raised one.
Rule: grep every PR for verify=False and require a named-bundle alternative.
🎯 Key Takeaway
verify=False deletes authentication instead of fixing trust — scope a real bundle to the host and ban the bypass in CI.

macOS Trust Store Quirks and the Fix

macOS adds a twist that confuses even experienced engineers: python.org installers ship their own CA bundle and their own OpenSSL, ignoring the Keychain entirely. Safari can trust a site — because Safari reads the Keychain — while the same Mac's Python raises CERTIFICATE_VERIFY_FAILED for the same URL. Neither side is wrong; they're reading different books of trusted roots.

The tell is the split: Keychain shows the root as trusted, Safari loads the page, but python -c "import requests" fails. Confirm by printing ssl.get_default_verify_paths() and certifi.where() — you'll see paths under /Library/Frameworks/Python.framework, nowhere near the Keychain. Homebrew Python behaves differently since it links system OpenSSL, which is why the same script can pass under brew Python and fail under a python.org install on one machine.

The fix matches the install. For python.org builds, run the Install Certificates.command bundled with your version (under /Applications/Python 3.x) to refresh the private bundle. For managed fleets, prefer exporting REQUESTS_CA_BUNDLE to a controlled PEM so every interpreter — python.org, brew, or venv — resolves the same roots. Either way, re-test inside the failing interpreter, not just the browser, because the browser was never the authority here.

📊 Production Insight
Support spent a week on 'works in Safari, fails in Python' tickets after a root rotation touched only the Keychain.
Symptom: identical failures across every python.org install, zero failures on brew Python.
Rule: test trust inside the failing interpreter; the browser's opinion doesn't transfer.
🎯 Key Takeaway
python.org Python ignores the Keychain — refresh its private bundle or pin REQUESTS_CA_BUNDLE so every interpreter agrees.

Reproducing and Reading the Error on Purpose

The fastest way to stop fearing an error is to raise it on purpose in a scratch file. Python's ssl module exposes SSLCertVerificationError with structured fields — verify_code and verify_message — that carry more signal than the str() form most tracebacks show. Building one deliberately teaches your hands the handling pattern: catch the narrow type, log the structured fields, and branch on the code instead of matching message text.

That last point deserves emphasis because message-text matching is brittle across OpenSSL versions. The human-readable prefix changes between releases ("self signed" versus "self-signed", extra bracketed codes), but the numeric verify codes are stable: 10 for expired, 18 for self-signed, 20 and 21 for chain gaps. Code that branches on verify_code survives upgrades; code that greps strings breaks on the next interpreter bump.

Use the snippet as a template for your error-handling drills. Raise it, catch it, print the fields, then re-raise so the drill mirrors production behavior where the error must propagate to your retry or alerting layer. Once catching the real type feels routine, live tracebacks stop looking like walls of red and start reading like lab reports with the conclusion highlighted.

reproduce_ssl_error.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"ok"
        self.send_response(200)
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass


tmp = tempfile.mkdtemp()
key, crt = tmp + "/k.pem", tmp + "/c.pem"
subprocess.run(
    [shutil.which("openssl"), "req", "-x509", "-newkey", "rsa:2048",
     "-keyout", key, "-out", crt, "-days", "1", "-nodes",
     "-subj", "/CN=127.0.0.1"],
    check=True, capture_output=True,
)
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
srv_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
srv_ctx.load_cert_chain(crt, key)
srv.socket = srv_ctx.wrap_socket(srv.socket, server_side=True)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
    raw = socket.create_connection(("127.0.0.1", port), timeout=10)
    ctx = ssl.create_default_context()
    tls = ctx.wrap_socket(raw, server_hostname="127.0.0.1")
    tls.close()
    print("UNEXPECTED: self-signed cert verified")
except ssl.SSLCertVerificationError as e:
    print("caught advertised error:", type(e).__name__)
    print("verify_code:", e.verify_code, "message:", e.verify_message)
    assert e.verify_code == 18
    raise
finally:
    srv.shutdown()
    srv.server_close()
📊 Production Insight
A team added string matching for 'self signed' that silently stopped matching after an OpenSSL upgrade reworded the message.
Symptom: fallback logic stopped triggering with no code change on their side.
Rule: branch on e.verify_code (18 here), never on the English wording around it.
🎯 Key Takeaway
Raise SSLCertVerificationError in a drill, branch on verify_code instead of message text, and always re-raise.
● Production incidentPOST-MORTEMseverity: high

The 8 AM Proxy Rotation That Turned Every CI Build Red for Two Hours

Symptom
All CI pipelines failed within minutes of each other with requests SSLError during pip install. Laptops on office Wi-Fi reproduced it; laptops on hotspots and staging containers did not. No application code had changed, and the package index was healthy.
Assumption
The team assumed PyPI's certificate had expired or certifi was outdated, since the error named certificate verification. Two engineers burned an hour upgrading certifi and rebuilding the deploy image. Nobody suspected the network path, because staging containers (on a different VLAN) built fine.
Root cause
The new proxy re-signed all outbound TLS, including connections to PyPI. Build containers used certifi's public bundle, which didn't contain the new proxy root, so every pip download died with CERTIFICATE_VERIFY_FAILED. A previous engineer had left --trusted-host in one Dockerfile months earlier, which masked the trust setup there and confused the diagnosis everywhere else.
Fix
The platform team exported the proxy's root CA, baked it into the base image, and set REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt in the build environment. They also pinned certifi and added a build-time smoke test: pip download of one tiny package before the real install. The next proxy rotation was a non-event.
Key lesson
  • SSLError during dependency install is a network-trust problem, not a code problem — check the proxy path before touching requirements.
  • Build environments need the same CA roots as laptops; a proxy root missing from CI images breaks every pipeline at once.
  • Any verify=False or --trusted-host workaround in a Dockerfile must be reverted the same day, or it becomes permanent.
Production debug guideFive checks that separate trust-bundle problems from real server failures — with the exact commands.5 entries
Symptom · 01
Requests fails but browsers and curl pass on the same machine
→
Fix
Run python -c "import certifi; print(certifi.where())" and pip show certifi to see which bundle Requests uses, then echo "REQUESTS_CA_BUNDLE=$REQUESTS_CA_BUNDLE" to check for an override. If the env var points at a stale or deleted file, that's your failure — update or unset it and re-run the request.
Symptom · 02
You need to know whether it's expired, self-signed, or a broken chain
→
Fix
Run openssl s_client -connect HOST:443 -servername HOST -showcerts < /dev/null and read the Verify return code plus the notBefore/notAfter dates. Code 10 is an expired leaf (renew it), code 18 is self-signed (install the private root), and code 20/21 is a missing intermediate (server must send its chain). Match the code before you change anything.
Symptom · 03
It fails on office Wi-Fi but passes on a phone hotspot
→
Fix
Run env | grep -i proxy and python -c "import requests; print(requests.utils.get_environ_proxies('https://x'))". If proxies appear only on the failing network, you're behind a MITM proxy. Fetch the corporate root CA, append it to a bundle copy, and export REQUESTS_CA_BUNDLE=/path/to/bundle.pem — then re-run the failing call.
Symptom · 04
Someone 'fixed' it with verify=False and you need the real fix
→
Fix
Run grep -rn "verify=False" --include="*.py" . and treat every hit as a bug. Replace each with verify="/path/to/internal-ca.pem" for internal hosts, or merge the needed root into the deployment bundle. Add the grep to CI so the bypass can't creep back in.
Symptom · 05
macOS trusts the site in Safari but Python still raises the error
→
Fix
Run ls -la /etc/ssl/cert.pem and python -c "import ssl; print(ssl.get_default_verify_paths())" to see which bundle your interpreter reads. For python.org installs, run /Applications/Python\ 3.14/Install\ Certificates.command, then confirm with python -c "import requests; print(requests.get('https://example.com', timeout=10).status_code)".
Requests SSLError Causes at a Glance
Root CauseHow to ConfirmFixPrevention
Expired leaf certificateopenssl s_client shows notAfter in the pastRenew the server cert; purge cached copiesExpiry alert 30 days out; uptime check on TLS
Self-signed or private CAIssuer equals subject; root unknown to bundleInstall the internal root CA into your bundleShip the root with onboarding docs and images
Incomplete chain (missing intermediate)openssl s_client shows verify error 20/21Serve the full chain; add the intermediateCI check that the served chain validates
Corporate MITM proxy root missingFails on office net, passes on hotspot; proxy env setAdd the proxy root CA; set REQUESTS_CA_BUNDLEStandard laptop image includes the proxy root
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
check_trust_store.pyprint("certifi bundle:", certifi.where())What CERTIFICATE_VERIFY_FAILED Actually Means
classify_ssl_error.pySAMPLES = {Expired vs Self-Signed vs Incomplete Chain
corporate_proxy_fix.pybundle = os.environ.get("REQUESTS_CA_BUNDLE", certifi.where())Corporate MITM Proxies
verify_false_demo.pyfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerWhy verify=False Is Never the Fix
reproduce_ssl_error.pyfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerReproducing and Reading the Error on Purpose

Key takeaways

1
CERTIFICATE_VERIFY_FAILED means no trusted chain was built, not that TLS is broken.
2
Read the message detail
expired, self-signed, and missing-intermediate need different fixes.
3
Corporate proxies re-sign traffic, so their root must live in the bundle Requests reads.
4
Resolution order is verify argument, then REQUESTS_CA_BUNDLE, then CURL_CA_BUNDLE, then certifi.
5
verify=False disables authentication entirely
use a per-host bundle instead.
6
On macOS, Python ships its own bundle, so Keychain trust alone won't clear the error.

Common mistakes to avoid

5 patterns
×

Treating every SSLError as the same bug

Symptom
You apply one random fix from the internet, it fails, and you try another. Hours pass because expired certs, self-signed certs, and broken chains all raise the same exception type with different messages.
Fix
Read the full message instead. If it says "certificate has expired," renew the server cert. If it says "self-signed certificate," install the internal CA. If it says "unable to get local issuer," fix the chain or bundle path. Each message maps to a different fix, so don't guess.
×

Reinstalling certifi to fix a corporate proxy failure

Symptom
pip install --upgrade certifi changes nothing. The proxy's MITM root isn't in any public bundle, so a fresh public bundle fails exactly the same way.
Fix
Install your company's root CA into the bundle Requests actually reads, or set REQUESTS_CA_BUNDLE to a PEM file that chains to it. Confirm with a test request before you change app code.
×

Setting verify=False to silence the error

Symptom
The error disappears but every HTTPS response can now be read or rewritten by anyone on the network path. You've traded a loud failure for silent exposure, and scanners will flag it.
Fix
Use verify="/path/to/internal-ca.pem" for that host, or merge the internal root into a custom bundle and export REQUESTS_CA_BUNDLE. Keep public traffic on the default bundle.
×

Setting the bundle path in your shell but not the service

Symptom
curl and local scripts pass while the deployed app keeps failing. The shell export never reached systemd, Docker, or the scheduler, so the app still uses the stock bundle.
Fix
Export REQUESTS_CA_BUNDLE (and SSL_CERT_FILE for non-Requests tools) in the service's environment or launch config, then verify with os.environ.get inside the running process.
×

Assuming macOS uses the Keychain for Python TLS

Symptom
The site loads in Safari but Python raises CERTIFICATE_VERIFY_FAILED. Apple's Python install ships its own bundle, so Keychain approvals don't help until the bundle is updated.
Fix
On macOS, run the Install Certificates.command for your Python version, or point REQUESTS_CA_BUNDLE at a bundle that includes the needed root. Re-test with python -c "import requests; requests.get(url, timeout=10)".
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does Requests actually check when verify=True?
Q02SENIOR
Why does Requests fail on office Wi-Fi but pass on a hotspot?
Q03SENIOR
How do you separate expired, self-signed, and incomplete-chain failures?
Q04SENIOR
Why is verify=False banned in production code?
Q05SENIOR
Why doesn't the macOS Keychain fix Python's verify failure?
Q01 of 05JUNIOR

What does Requests actually check when verify=True?

ANSWER
TLS verification checks two things: the server's certificate chains to a root you trust, and the hostname matches the cert. Requests implements this through urllib3 and OpenSSL using a CA bundle (certifi by default). verify=True uses the bundle, verify="/path/ca.pem" uses your file, and verify=False skips both checks entirely.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is verify=False ever acceptable?
02
Why does my script fail but the same code works elsewhere?
03
Why does curl succeed while Requests fails?
04
REQUESTS_CA_BUNDLE vs SSL_CERT_FILE — which one wins?
05
How do I tell expired, self-signed, and chain errors apart?
06
Do I need a fix on macOS even if Safari trusts the site?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
🔥

That's Errors. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Python ConnectionResetError 104 Fix
15 / 18 · Errors
Next
Python urllib3 MaxRetryError Fix
→