A normal HTTP proxy cannot cause SSL: CERTIFICATE_VERIFY_FAILED. With a CONNECT tunnel, TLS runs from your client to the website and the proxy never sees a certificate. If you get this error behind a proxy, one of two things is true: something in the path is re-signing the site with its own certificate, or your client is checking against the wrong CA bundle. On 5 October 2026 we reproduced the error 42 times across seven clients. The last words of the message tell you which case you have.
Tunnel or interception: the one question that matters
An HTTPS request through a proxy normally starts with CONNECT example.com:443. The proxy answers 200 Connection established and then just copies bytes. The certificate your client checks is the website's own, signed by a public CA, so verification passes just as it would without a proxy. In our lab, a plain tunnel returned 200 to requests, urllib, curl and Node with no extra settings. Proxy credentials, rotation and country targeting change nothing here, because none of them touch TLS.
An intercepting proxy also accepts the CONNECT, but then finishes the TLS handshake itself. It shows your client a certificate for example.com that it issued on the fly from its own root CA. Corporate SSL inspection, antivirus "web shields", debugging proxies and web unlockers all work this way. Your client has never seen that root, so it refuses the connection. That refusal is the error you're looking at. It isn't a bug: it's TLS doing its job.
You can tell which case you have in one command. Ask for the certificate through the proxy and read the issuer:
openssl s_client -proxy PROXY_HOST:PORT -connect example.com:443 \
-servername example.com </dev/null 2>/dev/null | grep -E " s:| i:|Verify return"
Through our plain tunnel, the issuer was a public CA and verification succeeded. Through the intercepting proxy, it was the lab's own root, and openssl printed Verify return code: 19 (self-signed certificate in certificate chain). If the issuer names your company, your antivirus or a debugging tool, you've found the cause.
What each error tail means
Python, curl and Node all start the message the same way and put the diagnosis at the end. These are the three endings we reproduced and what each one meant in our tests:
| End of the message | curl / Node wording | What happened | Fix |
|---|---|---|---|
self-signed certificate in certificate chain | curl (60) same text; Node SELF_SIGNED_CERT_IN_CHAIN | An intercepting proxy sent its own root along with the forged certificate. Your trust store doesn't include that root. | Trust that root CA for this client only |
unable to get local issuer certificate | curl (60) same text; Node UNABLE_TO_VERIFY_LEAF_SIGNATURE | Either the proxy sent only the leaf, or your CA bundle lacks the issuer. We also triggered it on a plain tunnel by pointing REQUESTS_CA_BUNDLE at a single CA file. | Use a bundle that contains both the public roots and the proxy's CA |
self-signed certificate | curl (60) same text; Node DEPTH_ZERO_SELF_SIGNED_CERT | The proxy presents one self-signed certificate per host, with no CA behind it | Fix the proxy, or trust that exact certificate |
The middle row causes the most confusion. The same text appears when an interceptor sends an incomplete chain and when your own configuration has thrown away the public roots. If the error started right after you set a CA variable, suspect the variable first.
Python: which setting your library actually reads
This is where most of the time goes, because each library reads a different setting. We ran the same intercepted request with each setting, from a separate process so nothing leaked between runs:
| Setting | requests 2.34.2 | httpx 0.28.1 | Effect on a normal site |
|---|---|---|---|
REQUESTS_CA_BUNDLE=ca.pem | Intercepted request passes | Ignored, still fails | requests now fails on a plain tunnel with "unable to get local issuer certificate" |
SSL_CERT_FILE=ca.pem | Ignored, still fails | Intercepted request passes | Not tested for httpx |
verify="ca.pem" (requests) / SSL context with cafile (httpx, aiohttp) | Passes | Passes | Scoped to that call or client |
verify= a bundle of public roots plus the proxy CA | Passes | not run | Plain tunnel also passes: 200 |
verify=False | Passes, emits InsecureRequestWarning | not run | Verification is off for everything |
Two results from this table are worth remembering. First, REQUESTS_CA_BUNDLE replaces the trust store rather than adding to it. Point it at your proxy's CA alone and every site you reach through a normal tunnel breaks with a different error. Build a combined file instead:
import certifi, requests
# public roots + the intercepting proxy's root, in one file
with open("combined.pem", "w") as out:
out.write(open(certifi.where()).read())
out.write(open("proxy-ca.pem").read())
proxies = {"https": "http://USER:PASS@PROXY_HOST:PORT"}
r = requests.get("https://example.com/", proxies=proxies,
verify="combined.pem", timeout=30)
print(r.status_code)
Second, httpx and requests don't share environment variables. A fix that works in a requests script won't carry over when you switch libraries. With httpx or aiohttp, pass an explicit context and you won't depend on either variable:
import ssl, httpx
ctx = ssl.create_default_context(cafile="combined.pem")
with httpx.Client(proxy="http://USER:PASS@PROXY_HOST:PORT", verify=ctx) as client:
print(client.get("https://example.com/").status_code)
aiohttp takes the same context through its ssl= argument. With the default settings, aiohttp raised ClientConnectorCertificateError wrapping the same "self-signed certificate in certificate chain" text. Python's built-in urllib behaved like the others: 200 through the plain tunnel, the same CERTIFICATE_VERIFY_FAILED through the interceptor.
pip behind a proxy
"pip proxy ssl certificate_verify_failed" is the top autocomplete for this error, and pip 25.2 behaves differently from the libraries above. It checks certificates against the operating system's trust store. On macOS, our intercepted install failed with a system message saying the lab CA "is not trusted", not the usual OpenSSL wording. With --use-deprecated=legacy-certs, pip went back to its bundled roots and printed the classic self-signed certificate in certificate chain.
That has two practical consequences. If your company already pushes its inspection CA into the system store, current pip usually works with no flags. If it doesn't, give pip the CA explicitly and keep verification on:
pip install --proxy http://USER:PASS@PROXY_HOST:PORT --cert proxy-ca.pem package-name
# or once, in pip.conf / pip.ini:
# [global]
# cert = /path/to/proxy-ca.pem
In our run, --cert got pip past the handshake. Avoid --trusted-host as a permanent fix, because it turns off verification for that host.
curl and Node: two traps
curl --proxy-insecure does not help. It skips checking the certificate of an HTTPS proxy, meaning the proxy's own TLS. It doesn't affect the certificate presented for the target. Through our intercepting proxy, curl 8.7.1 still exited with code 60 when given --proxy-insecure. --cacert proxy-ca.pem returned 200, as did -k, which turns verification off entirely.
curl --proxy http://PROXY_HOST:PORT --proxy-user 'USER:PASS' \
--cacert ./proxy-ca.pem 'https://example.com/'
Node appends, Python replaces. Starting Node with NODE_EXTRA_CA_CERTS=proxy-ca.pem fixed the intercepted request and left the plain tunnel working: both returned OK in the same process. That's the behaviour most people expect from REQUESTS_CA_BUNDLE, and it's exactly what that variable does not do.
When the error is not about certificates at all
A common mistake is writing the proxy URL as https:// when the proxy speaks plain HTTP. It looks like a TLS problem but it isn't one. With proxies={"https": "https://PROXY_HOST:PORT"}, requests opened TLS to the proxy itself. When our test proxy replied with a plain-text 400, requests raised a ProxyError saying the proxy "appears to only use HTTP and not HTTPS". When the proxy stayed silent instead, the call hung until the 15-second read timeout. Neither case produces CERTIFICATE_VERIFY_FAILED. The fix is the scheme: the key is "https" because the target is HTTPS, and the value stays http:// because the proxy is HTTP. We cover how to read that error in requests.exceptions.ProxyError, explained, and the tunnel-level failures in ERR_TUNNEL_CONNECTION_FAILED.
Where this applies on QuanticData
The residential, mobile, ISP and datacenter gateways are CONNECT tunnels: TLS stays between your client and the site. If you see CERTIFICATE_VERIFY_FAILED on one of them, look for an interceptor on your side (office network, antivirus, a debugging proxy left running) or a CA variable you set yourself. The proxy tester tells you whether the gateway itself answers.
The Web Unlocker is different, and deliberately so. It ends your TLS session so it can read and rewrite the request, then opens its own connection to the site. Every HTTPS request through it fails verification until your client trusts its CA. Download the certificate with your API key and pass it to the client. Don't turn verification off:
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://app.quanticdata.io/api/v1/scraper/unlock/ca -o qp-unlocker-ca.pem
curl --proxy http://unlock.quanticdata.io:9000 \
--proxy-user 'unlock-YOUR_ID-country-us:YOUR_PROXY_PASSWORD' \
--cacert ./qp-unlocker-ca.pem 'https://example.com/'
If you'd rather not manage certificates at all, the web scraping API takes a URL over a normal HTTPS call to our API and returns the page. There's no proxy in your client and no CA to trust, and failed requests are not billed.
A five-step checklist
- Read the end of the error message and match it to the table above.
- Run
openssl s_client -proxyand check who issued the certificate. - If it's an interceptor you trust, get its root CA as a PEM file.
- Add that CA for this client only: a combined bundle for requests, an SSL context for httpx and aiohttp,
--certfor pip,--cacertfor curl,NODE_EXTRA_CA_CERTSfor Node. - Re-run against both an intercepted site and a normal one, so a replaced bundle can't hide behind a fix that only works for one of them.
Our full checklist for proxy errors that aren't about certificates is in proxy not working: the checklist.