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..
20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.
- ✓Basic Python and pip installed locally
- ✓You've made an HTTPS request with Requests before
- ✓Access to a terminal with openssl available
- 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
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.
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.
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.
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.
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.
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.
The 8 AM Proxy Rotation That Turned Every CI Build Red for Two Hours
- 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.
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.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.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.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.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)".| File | Command / Code | Purpose |
|---|---|---|
| check_trust_store.py | print("certifi bundle:", certifi.where()) | What CERTIFICATE_VERIFY_FAILED Actually Means |
| classify_ssl_error.py | SAMPLES = { | Expired vs Self-Signed vs Incomplete Chain |
| corporate_proxy_fix.py | bundle = os.environ.get("REQUESTS_CA_BUNDLE", certifi.where()) | Corporate MITM Proxies |
| verify_false_demo.py | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | Why verify=False Is Never the Fix |
| reproduce_ssl_error.py | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | Reproducing and Reading the Error on Purpose |
Key takeaways
Common mistakes to avoid
5 patternsTreating every SSLError as the same bug
Reinstalling certifi to fix a corporate proxy failure
Setting verify=False to silence the error
Setting the bundle path in your shell but not the service
Assuming macOS uses the Keychain for Python TLS
Interview Questions on This Topic
What does Requests actually check when verify=True?
Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.
That's Errors. Mark it forged?
5 min read · try the examples if you haven't