Why the browser and curl work but Python fails
This is Part 7 of the Python development guide. This installment covers the Python HTTPS error most often reported in corporate-proxy and closed-network environments.
A typical traceback looks like this.
Traceback (most recent call last):
File ".../urllib3/connectionpool.py", line 715, in urlopen
File ".../urllib3/connectionpool.py", line 1058, in _validate_conn
File ".../urllib3/connection.py", line 419, in connect
File ".../ssl.py", line 517, in wrap_socket
File ".../ssl.py", line 1108, in _create
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1000)
During handling of the above exception, another exception occurred:
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=443):
Max retries exceeded with url: /v1/ping
(Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] ...')))I'll pin this article's conclusion in one line first.
The OS trust store ≠ the certifi bundle. Browsers and curl consult the OS (the Windows certificate store, macOS Keychain, Linux
/etc/ssl/certs), but Pythonrequestsby default only looks at thecertifiPEM file shipped with the package. That is why Python fails on its own even after corporate IT has deployed the root CA to the OS.
If your company runs SSL inspection appliances such as Zscaler, Netskope, or Palo Alto, this symptom is closer to the default than an exception. The appliance terminates TLS in the middle and re-signs with a corporate root CA that Python does not know about.
Five-way error-text decision table: the verify code number names the culprit
The number in parentheses after the error message, or Verify return code: NN from openssl s_client output, is the decisive clue.
| Error text | verify code | Actual cause | First-priority action | Check command |
|---|---|---|---|---|
unable to get local issuer certificate | 20 | Corporate root CA is missing from certifi, or the server is not sending the intermediate certificate | Load the root CA into Python (Section 4) | openssl s_client -connect host:443 -showcerts |
self signed certificate in certificate chain | 19 | Corporate MITM proxy is re-signing traffic | Register the proxy root CA (Section 4 b/d) | Check whether the top-of-chain Issuer is Zscaler/Netskope |
certificate has expired | 10 | Server certificate expired, an expired cross-sign path was selected (DST Root CA X3 leftovers), or system clock skew | Inspect the server chain + check date | openssl s_client ... | openssl x509 -noout -dates |
Hostname mismatch, certificate is not valid for 'x.y.z' | — | SNI not sent, connecting by IP, or wildcard depth (*.a.com does not cover b.c.a.com) | Connect by domain or check SAN | openssl x509 -noout -text | grep -A1 "Subject Alternative Name" |
unable to get local issuer certificate on macOS only | 20 | python.org installer: Install Certificates.command was never run → certifi link missing | Run that script once (Section 4, OS-specific) | python3 -c "import certifi;print(certifi.where())" |
Raw messages are also left in a code block for searchability.
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'api.example.com'30-second diagnosis: pin the cause with three commands
① Confirm the bundle path Python actually uses
python -c "import ssl, certifi; print(certifi.where()); print(ssl.OPENSSL_VERSION)"Expected healthy output:
/usr/lib/python3.11/site-packages/certifi/cacert.pem
OpenSSL 3.0.13 30 Jan 2024- The path prints but it still fails → that bundle does not contain the corporate CA. Go to Section 4.
ModuleNotFoundError: certifi→pip install certifi, or you are in the wrong virtualenv.- If multiple virtualenvs are mixed up, pin the interpreter first with
python -c "import sys;print(sys.executable)".
② Confirm the chain the server actually presents
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -E "^(depth|verify|Verify| [0-9] s:| [0-9] i:)"Healthy case (public CA):
0 s:CN=api.example.com
i:C=US, O=Let's Encrypt, CN=R11
1 s:C=US, O=Let's Encrypt, CN=R11
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Verify return code: 0 (ok)Corporate SSL inspection case:
0 s:CN=api.example.com
i:CN=Zscaler Intermediate Root CA (zscaler.net)
1 s:CN=Zscaler Intermediate Root CA (zscaler.net)
i:CN=Zscaler Root CA
Verify return code: 19 (self signed certificate in certificate chain)If the Issuer (i:) shows a company name or security-appliance name, diagnosis is done. MITM proxy environment → load the corporate root CA into Python.
If the chain shows only depth 0 and Verify return code: 20, the server omitted the intermediate certificate. The proper fix is to ask the server admin to deploy the full chain (fullchain). If this is a certificate issuance/renewal issue, also see Fixing certbot renewal failures: troubleshooting certificate expired by cause.
③ Compare curl vs Python to confirm the split architecture
curl -v https://api.example.com/ 2>&1 | grep -E "CAfile|SSL certificate|subject|issuer"
python - <<'PY'
import requests
try:
requests.get("https://api.example.com/", timeout=5)
print("PY OK")
except Exception as e:
print("PY FAIL:", e)
PY| curl | Python | Diagnosis |
|---|---|---|
| Success | Fail | CA is in the OS store but not in certifi → Section 4 (b)(d) recommended |
| Fail | Fail | CA is missing from the OS as well → start with OS-level root registration in Section 4 |
| Success | Success | Code / proxy environment-variable issue → Section 6 failure branches |
Note that Python's CA configuration is completely separate from Java's -Djavax.net.ssl.trustStore family of options. If you hit the same symptom on a Java stack, see PKIX path building failed / SunCertPathBuilderException 30-minute runbook.
The proper fix by environment: four ways to load a corporate root CA into Python
(a) Priority order: verify= and the three environment variables
| Variable | Scope | Notes |
|---|---|---|
verify= argument | That request/session | Always highest priority. If hardcoded in code, env vars are ignored |
REQUESTS_CA_BUNDLE | requests family only | Read directly by requests. Takes precedence over CURL_CA_BUNDLE |
CURL_CA_BUNDLE | curl + requests fallback | Used by requests when REQUESTS_CA_BUNDLE is unset |
SSL_CERT_FILE / SSL_CERT_DIR | Python ssl · OpenSSL global | Applies across standard contexts: aiohttp, httpx, urllib, etc. |
Priority is verify= → REQUESTS_CA_BUNDLE → CURL_CA_BUNDLE → (requests default: certifi). Libraries that do not use requests look at SSL_CERT_FILE. If you are rolling this out company-wide, SSL_CERT_FILE has broader coverage.
(b) The truststore module — recommended #1 as of 2026
On Python 3.10+, truststore makes Python use the OS trust store as-is. If corporate IT has already deployed the root CA to the OS, you do not need to manage extra files.
pip install truststoreimport truststore
truststore.inject_into_ssl # after SSLContext OS save
import requests
print(requests.get("https://api.example.com/", timeout=5).status_code)Call it once at the application entry point (top of main.py, Django settings.py, and so on). Recent pip versions also support using the system store via --use-feature=truststore and similar flags, so check the official docs for your pip version for option support, then apply it.
(c) pip-system-certs
pip install pip-system-certsOnce installed, it patches requests/pip to use the system store. Useful for third-party CLI tools you cannot modify, but it monkey-patches at import time, so for production services where you want the behavior to be explicit, prefer (b).
(d) Bundle merge — never edit certifi's original file
If you edit certifi's cacert.pem directly, it gets wiped the moment you pip install --upgrade certifi. Create a separate merged file.
sudo mkdir -p /opt/ca
cat "$(python -m certifi)" /path/to/corp-root.crt > /opt/ca/corp-bundle.pem
#
export SSL_CERT_FILE=/opt/ca/corp-bundle.pem
export REQUESTS_CA_BUNDLE=/opt/ca/corp-bundle.pem
#
python -c "import requests;print(requests.get('https://api.example.com/',timeout=5).status_code)"If things are healthy, it prints 200. If it still fails, check that the merged file contains at least two -----BEGIN CERTIFICATE----- blocks, and that the corporate CA is not in DER format. If it is DER, convert it.
openssl x509 -inform der -in corp-root.der -out corp-root.crtOS-specific root CA registration
# Ubuntu / Debian
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates # "1 added"
# RHEL / Rocky
sudo cp corp-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract# macOS: python.org
/Applications/Python\ 3.12/Install\ Certificates.command
#
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-root.crt# Windows ( PowerShell)
certutil -addstore -f "Root" C:\certs\corp-root.crt
Import-Certificate -FilePath C:\certs\corp-root.crt -CertStoreLocation Cert:\LocalMachine\RootComparison of the four methods
| Method | Code change | Reinstall resilience | Coverage | Recommended rank |
|---|---|---|---|---|
| truststore | 2 lines needed | Strong (OS-based) | Standard ssl overall | ★1 |
| OS root registration + truststore | None (when combined) | Strong | All OS tools | ★1 |
Bundle merge + SSL_CERT_FILE | None | Strong (separate file) | Python overall | ★2 |
| pip-system-certs | None | Medium | requests/pip | ★3 |
| Direct certifi edit | None | None | requests only | Forbidden |
Why verify=False is not a fix, plus related cases
The real risk of verify=False
verify=False means "do not verify the certificate," not "connect securely." If the same code runs on cafe Wi-Fi or an external network, anyone can insert themselves in the middle with their own certificate and read API keys in request headers and the response body as-is. The assumption that it will only run inside the corporate proxy breaks as soon as a single deployment environment changes.
If you must use it temporarily, leave at least this much behind.
# ⚠️ TODO(2026-08-31 ): dedicated. .
# CA (INFRA-1234) after verify=/opt/ca/corp-bundle.pem replace
import urllib3, requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
requests.get("https://internal.example.local/health", verify=False, timeout=5)Code that disables the warning is a sign you hid the problem. Do not commit it without an expiry comment and a ticket number.
When pip install throws the same error
pip install requests \
--trusted-host pypi.org \
--trusted-host files.pythonhosted.org \
--trusted-host pypi.python.orgFor a permanent setting, put it in the config file. (Linux/macOS ~/.config/pip/pip.conf, Windows %APPDATA%\pip\pip.ini)
[global]
cert = /opt/ca/corp-bundle.pem
index-url = https://pypi.org/simple
[install]
trusted-host =
pypi.org
files.pythonhosted.orgcert= (the proper fix) and trusted-host (verification bypass) are different in nature. Prefer cert= whenever you can.
When it only fails in Docker
python:3.12-slim and Alpine-based images often omit or minimize the CA bundle package, so failures that only happen in containers are common.
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# CA
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crtFor Alpine, use RUN apk add --no-cache ca-certificates && update-ca-certificates. If you are already blocked at image build time (pip install), place the CA copy before pip install.
Per-library configuration cheat sheet
| Library | Specify corporate CA | Disable verification (not recommended) |
|---|---|---|
| requests | requests.get(url, verify="/opt/ca/corp-bundle.pem") | verify=False |
| httpx | httpx.Client(verify="/opt/ca/corp-bundle.pem") | verify=False |
| aiohttp | ssl=ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem") | ssl=False |
| urllib3 2.x | PoolManager(ca_certs="/opt/ca/corp-bundle.pem") | cert_reqs="CERT_NONE" |
| stdlib urllib | urlopen(url, context=ssl.create_default_context(cafile=...)) | — |
import ssl, aiohttp
ctx = ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem")
async def fetch(url):
async with aiohttp.ClientSession() as s:
async with s.get(url, ssl=ctx) as r:
return await r.text()From urllib3 2.x onward, OpenSSL 1.1.1+ is required and TLS policy is stricter. If you hit SSLError: [SSL: UNSUPPORTED_PROTOCOL] or a handshake failure while talking to older equipment, it is a protocol negotiation issue, not a CA issue, so Section 4 of this article will not fix it. Bumping the server-side TLS version is the proper fix.
When it still fails: three failure branches
- Misconfigured proxy environment variables — Check
HTTPS_PROXYandNO_PROXYwithenv | grep -i proxy. A common case is a corporate API missing fromNO_PROXY, so traffic goes through the proxy and gets re-signed. Uppercase vs lowercase variants (https_proxy) set to different values are also reported often. - System clock skew — If a container or VM clock is badly skewed, even a valid certificate surfaces as
certificate has expired(err 10). Comparedate -uwith actual UTC and confirm NTP sync. - OpenSSL 3.x policy differences — You can fail due to blocked legacy renegotiation with older servers, rejected SHA-1 signatures, and similar. In that case the error text changes to something other than
CERTIFICATE_VERIFY_FAILED, so re-read the raw message first.
Recapping the diagnosis flow:
check
├ unable to get local issuer (20) ─ openssl s_client check
│ ├ Issuer / → CA (truststore or )
│ └ 1 → fullchain Request
├ self signed in chain (19) ────── MITM → CA
├ certificate has expired (10) ─── date check → certificate expiry check
└ hostname mismatch ───────────── SAN/SNI/IP checkGo or Docker x509: certificate signed by unknown authority, and Java PKIX errors, share the same causal structure, but the file paths you fix are completely different. See the per-stack articles separately.
Next installment (Python Development Guide, Part 8) covers ModuleNotFoundError / ImportError — how to fully diagnose Python import-path problems from the angles of sys.path, package layout, and virtualenvs.
Frequently asked questions (FAQ)
Q. curl works but only Python throws an SSL error. Why?
A. Because curl uses the OS trust store, while requests by default uses the certifi bundle shipped with the package. If corporate IT deploys the root CA only to the OS, Python never sees that CA. Use truststore.inject_into_ssl() or point SSL_CERT_FILE at a merged bundle path.
Q. I set REQUESTS_CA_BUNDLE and it still fails.
A. If verify= is hardcoded in code, it takes precedence over the environment variable. Also, REQUESTS_CA_BUNDLE applies only to the requests family, so for aiohttp, httpx, and stdlib urllib you need to set SSL_CERT_FILE as well. Check file permissions too (a service running as another user may not be able to read the file).
Q. Is it OK to just pass verify=False? A. That turns verification off entirely, leaving you wide open to man-in-the-middle attacks. For short-lived uses such as closed-network testing, leave a removal deadline and ticket number in a comment, and in production code always apply the proper fix: register the corporate root CA.
Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us
This article has no recorded human check (date and scope) yet.
Comments
Be the first to comment.