Safeguard
Application Security

How to actually implement TLS correctly in Python

One `verify=False` in a requests call disables both certificate and hostname checks — the same escape hatch PEP 476 tried to close in 2014.

Safeguard Research Team
Research
5 min read

For years, Python's standard library shipped HTTPS clients that didn't check certificates at all. Until PEP 476 landed in Python 2.7.9 (released December 2014) and 3.4.3 (released February 2015), urllib and http.client happily completed a TLS handshake with any certificate a server presented, valid or not, and never checked whether the hostname in the cert matched the hostname you connected to. That meant a large share of Python code written before that point was accepting man-in-the-middle attacks by design, not by bug. The fix didn't fully solve the problem, either: PEP 493 immediately followed with an opt-out environment variable, PYTHONHTTPSVERIFY=0, for organizations not ready to break existing deployments — though that opt-out was specific to Python 2.7 and was never forward-ported to Python 3. Python 2 reached end-of-life in January 2020, so the lingering risk today is legacy Python 2 systems and vendor-patched forks that still carry the flag, not the current Python 3 stdlib. Today the more common failure mode has moved from "the interpreter doesn't verify" to "a developer explicitly told it not to" — most often via requests.get(url, verify=False), a single keyword argument that silently reintroduces the exact vulnerability PEP 476 closed. This post walks through where Python TLS still goes wrong and the concrete patterns that fix it.

Why did Python ship insecure-by-default HTTPS for so long?

Python's HTTPS clients skipped verification by default because ssl.wrap_socket() and the early httplib/urllib2 APIs were built around raw socket wrapping, and no one made certificate validation the default behavior when TLS support was first added. PEP 476, authored by Alex Gaynor and accepted for Python 2.7.9 and 3.4.3, changed http.client, urllib.request, and related stdlib modules to call ssl.create_default_context() and validate both the certificate chain and hostname automatically. Before that release, a developer had to opt in to verification manually, and most never did, because the unverified path was the one every tutorial and code sample used without comment. The change was significant enough that CPython's own release notes flagged it as backward-incompatible, since it broke any code silently talking to servers with expired, self-signed, or mismatched certificates.

Why does PEP 493's opt-out still cause incidents today?

PEP 493 added a migration escape hatch — the PYTHONHTTPSVERIFY=0 environment variable and a distro-level Python build flag, scoped to Python 2.7 — specifically so enterprises with existing self-signed internal certificate infrastructure wouldn't have every internal script break the moment they upgraded their Python 2.7 installs. The problem is that the opt-out is environment-wide: setting it doesn't fix one connection, it disables verification for every urllib/http.client call in that process, including ones talking to the public internet. Because it's set once in a base Docker image, a CI runner's environment, or a package's sitecustomize.py, it tends to persist long after the internal-certificate problem that justified it is gone, and it's invisible in application code — nobody reviewing a pull request sees PYTHONHTTPSVERIFY=0 because it isn't in the diff. Python 2 has been end-of-life since January 2020 and the flag was never carried into Python 3, so this risk today lives almost entirely in unmigrated legacy systems and old container base images, not in current Python deployments — but those legacy systems are exactly the ones least likely to get audited.

Why does requests' verify=False keep showing up in production code?

The requests library defaults to verify=True, checking every connection against the certifi CA bundle, but its verify parameter accepts False as a one-line override that disables both chain validation and hostname matching simultaneously. It shows up in production for a mundane reason: developers hit a certificate error while testing against a self-signed staging server or a corporate proxy that intercepts TLS, add verify=False to make the error go away, and the line survives the trip to production because tests still pass. requests and the underlying urllib3 do try to warn you — they raise an InsecureRequestWarning on every call made with verify=False — but the standard workaround developers copy from Stack Overflow is urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning), which suppresses the warning instead of fixing the cause. The correct fix in nearly every case is to point verify at the specific CA bundle or self-signed certificate you trust — for example verify="/path/to/internal-ca.pem" — rather than turning verification off entirely.

What's the right way to build a custom SSLContext?

When code needs more control than requests exposes — custom cipher suites, mutual TLS, or a non-standard trust store — the correct entry point is ssl.create_default_context(), not a bare ssl.SSLContext(). A default context comes pre-configured with certificate verification on, hostname checking on, and a sane minimum TLS version; a manually constructed SSLContext() starts with verify_mode set to CERT_NONE, meaning it validates nothing until you explicitly configure it. This is also where ordering bugs creep in: check_hostname and verify_mode are interdependent in the stdlib implementation, and setting check_hostname = True while verify_mode is still CERT_NONE raises a ValueError, which leads some developers to "fix" the crash by setting check_hostname = False instead of raising verify_mode to CERT_REQUIRED first — silencing the safety check that caught their misconfiguration in the first place.

How should teams manage custom CA bundles without disabling verification?

The pattern that avoids all of the above is trusting a specific, named CA bundle rather than turning verification off — every major Python HTTP client supports this. In requests, pass the bundle path directly as the verify argument; in the stdlib, load it into a default context with context.load_verify_locations(cafile="/path/to/ca-bundle.pem") instead of touching verify_mode. This is also standard practice outside Python: Safeguard's own CLI, which is frequently run inside enterprise networks with internal TLS-intercepting proxies, follows the same principle — its network configuration accepts a api.ca_bundle setting (or the NODE_EXTRA_CA_CERTS environment variable) so it can trust an organization's internal CA without disabling certificate validation for every connection it makes. The rule scales down to any Python service: add the CA you need to trust, don't remove the check.

Never miss an update

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