Safeguard
Application Security

ssl vs. requests vs. httpx: where Python TLS configuration goes wrong

Python didn't verify TLS certificates by default until PEP 476 landed in 2014 — a decade later, one stray verify=False call still reopens that exact hole.

Safeguard Research Team
Research
5 min read

For nearly two decades, Python's standard-library ssl module could open an HTTPS connection without ever checking that the certificate on the other end was valid. That changed with PEP 476, which shipped in Python 2.7.9 and 3.4.3 in December 2014 and made certificate verification the default for stdlib HTTP clients — but the raw ssl.SSLContext() constructor still defaults to CERT_NONE today if you build one by hand instead of calling ssl.create_default_context(). That gap between "the ecosystem is secure by default" and "any given line of code is secure by default" is where most Python TLS bugs live. requests and httpx, the two most widely used third-party HTTP clients, both verify certificates out of the box using the certifi CA bundle — yet both expose a single boolean, verify=False, that silently disables certificate and hostname checking entirely, and both have shipped real CVEs in supporting code even with verification switched on. This post walks through where ssl, requests, and httpx differ on TLS defaults, two concrete CVEs that show what goes wrong in practice, and the specific settings that enforce certificate validation and modern TLS versions correctly across all three.

Why didn't Python verify certificates by default until 2014?

Before PEP 476, ssl.wrap_socket() and manually constructed SSLContext objects defaulted to CERT_NONE, meaning a Python script could complete a TLS handshake with any server presenting any certificate — self-signed, expired, or issued to a different hostname entirely — without raising an error. PEP 476 changed the default construction path used by http.client, urllib.request, and other stdlib consumers to call the equivalent of ssl.create_default_context(), which sets verify_mode=CERT_REQUIRED, enables check_hostname, and loads the system trust store. Critically, this fix applies to how stdlib clients build their context, not to the ssl module's low-level API itself: a developer who writes ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) today and hands it to a socket without setting verify_mode and check_hostname explicitly gets the pre-2014 behavior back. This is why security reviewers still flag hand-rolled SSLContext construction in Python codebases more than a decade after the CVE-era fix shipped.

What makes requests' verify=False so risky in practice?

requests sets verify=True by default, which validates the server certificate against the certifi bundle and checks hostname match — but verify=False is a one-line escape hatch that developers frequently reach for to get past a self-signed certificate error in a test or staging environment, then forget to remove before shipping to production. With verification off, requests (via its urllib3 dependency) will happily complete a TLS handshake with an attacker sitting in the traffic path, since neither the certificate chain nor the hostname is checked. urllib3 does emit an InsecureRequestWarning when this happens, but warnings are routinely suppressed in production logging configs precisely because teams already know about the verify=False call and don't want the noise — which means the warning stops functioning as a safety net the moment it becomes expected. The correct fix for a private or internal CA is never verify=False; it's passing a specific trust bundle: requests.get(url, verify="/path/to/internal-ca-bundle.pem").

What did CVE-2018-18074 reveal about redirect handling?

CVE-2018-18074 showed that certificate validation alone doesn't guarantee a safe connection if the client mishandles what happens after the handshake. Versions of requests before 2.20.0 failed to strip the Authorization header when following a same-hostname redirect from an HTTPS URL to an HTTP URL, meaning a bearer token or basic-auth credential sent to a trusted API could be replayed in plaintext over that now-unencrypted connection, where anyone sniffing the network path could read it. The bug had nothing to do with the TLS handshake itself — verify=True was irrelevant to it — and everything to do with client-side request-forging logic that didn't treat a scheme or host change as a trust boundary. It was fixed in requests 2.20.0, and it's a useful reminder that pinning certificate validation correctly is necessary but not sufficient: teams should also confirm they're on a requests version well past 2.20.0, since older pins can still carry this class of credential-leak risk even with TLS otherwise configured correctly.

How does httpx's TLS model differ from requests?

httpx also verifies certificates by default via certifi, but its ssl.SSLContext-first design gives finer, more explicit control than requests offers. Where requests mostly exposes verify as a bool-or-path shortcut, httpx.Client(verify=ssl_context) accepts a fully constructed SSLContext, which makes it straightforward to configure mutual TLS with a client certificate, pin a minimum TLS version, or load a custom CA for a private PKI without reaching for lower-level monkey-patching. httpx also natively supports HTTP/2 and both sync and async clients sharing one connection pool and one SSLContext, so a minimum-TLS-version or cipher policy set once on a shared client applies consistently across every request the async app makes — rather than being re-specified (or forgotten) per call site, which is a common drift point in larger requests-based codebases that construct sessions in multiple places.

Which TLS versions and ciphers should be enforced explicitly?

RFC 8996, published in March 2021, formally deprecated TLS 1.0 and TLS 1.1 across the industry, and Python's ssl module has followed suit: ssl.create_default_context() on Python 3.10 and later effectively enforces a TLS 1.2 floor and excludes compression and known-weak cipher suites from its default context. Teams that need to be explicit — for compliance audits or defense in depth — should still set context.minimum_version = ssl.TLSVersion.TLSv1_2 directly rather than relying on defaults staying put across Python upgrades. The same discipline applies to dependencies, not just direct code: urllib3, which both requests and parts of httpx's ecosystem lean on, shipped CVE-2021-33503, a regular-expression denial-of-service triggerable through a crafted URL containing many @ characters during authority parsing, fixed in urllib3 1.26.5. A correctly configured SSLContext doesn't help if the HTTP layer parsing the URL in front of it is running a vulnerable, unpatched version — which is why dependency-version hygiene for certifi and urllib3 is as much a part of "TLS configuration" as the handshake settings themselves.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.