A compromised certificate authority means some Python builds quietly trust the wrong things. Here is the version floor, the cached-layer trap, and a check you can run today.
A security researcher handed proof to the affected root programs on October 17 that private keys for eight eMudhra intermediate certificate authorities had been compromised. The disclosure went public three days later, on October 20. Those intermediates sign leaf certificates under a parent emSign root that plenty of Python tooling trusts without asking. An attacker holding one of those intermediate keys can mint a certificate for any domain that chains to that root, and a client still carrying the old bundle will accept it. Your build won't warn you. Your deploy won't warn you. It just works, against the wrong server.
If you own a Python service, a container image, or a CI pipeline that installs anything over HTTPS, there is one boring file worth checking: the certificate bundle your tools carry. It's versioned software. Like all versioned software, it can be months out of date and still run perfectly.
A build you can follow the whole way through
Take a configuration that's easy to recognise. Your service ships as a container image. The Dockerfile pins its base by digest — FROM python:3.13.16-slim@sha256:…, a patch release from before the October 21 security updates — so every build starts from the same immutable filesystem. Inside that image there's a system CA store from the ca-certificates package, a pip carrying its own vendored bundle, and a project virtual environment where you installed certifi so that requests works. CI does dependency installs with uv. Four separate places can decide whether a TLS connection is trusted, and this build uses all four.
Nothing about that is unusual. That's the whole problem. A setup like this passes its tests, deploys on schedule and gives you no signal that its trust decisions are months old. You get told about a CA incident on a Tuesday, you look at your own code, and there's nothing to see, because the vulnerable thing isn't your code. It's a wheel that came from PyPI, a binary you downloaded, a base image somebody else built.
The one practical task in this whole article: find every trust layer your build actually consults and update each one. A TLS connection from your build is trusted or not according to the specific bundle some component loads, so name those components — the system store, certifi, the vendored bundle inside pip, uv's embedded roots — and refresh each one. Then check what your build really loads at 3 a.m., because the version in your terminal and the version in your runner are often two different things.
The version floor
The Python Security Advisory lists these minimum versions, whose bundled certificate stores removed trust in the compromised emSign intermediates:
certifi 2026.10.19 — uploaded October 20 (removing compromised intermediates)
pip 26.2.2 — released October 20, ships vendored certifi 2026.10.19 in its wheels
uv 0.13.4 — released October 20, updates its own bundled Mozilla roots (0.13.5 adds managed CPython build updates)
sigstore 4.5.1, conda 26.9.4, pixi 0.82.1 (related toolchain updates)
Treat those numbers as upstream floors rather than universal truths. A downstream distributor — the Debian python3-pip package, the Fedora pip RPM, a Docker image that patches its CA bundle — may have backported the fix and kept an older version number. The question that matters is whether your actual bundle still trusts the compromised intermediates. A version string matching the floor tells you someone shipped the fix; it doesn't tell you your build received it.
So verify directly. Check your running tool's certifi location, your pip version, your uv version, and the base image you build on. That takes about four commands and saves you an afternoon of guessing.
There's a bootstrapping problem in the middle of this. If your pip trusts the old bundle, then pip install --upgrade pip runs over a connection secured by that same old bundle. You'd be using the compromised layer to fetch its own replacement. The advisory's safe-bootstrap command breaks that circle.
Safe bootstrap for pip
The advisory gives one command for getting a pip you can trust:
python -m pip install \
"pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
The URL carries the wheel's SHA-256 hash. When you've taken that hash from a trusted source — the advisory itself — pip checks the downloaded bytes against the expected digest before installing anything. An attacker who has already intercepted your TLS connection can't swap the wheel for a different one without detection, because different bytes produce a different hash and the install fails. The worst they can do is block or fail the download. That's a real attack, but it's a noisy one, and noisy is what you want.
Once the trusted pip is in place, upgrade certifi:
python -m pip install --upgrade certifi
This wheel needs Python 3.10 or later, per its PyPI metadata. On Python 3.9 the wheel won't install at all. Some Linux distributions patch their packaged pip to use the system trust store through an updated ca-certificates package; check your distribution's security advisory before assuming either way. If distribution patches aren't available, you can install packages with uv instead — uv 0.13.4 needs Python 3.8 or later and carries its own fixed roots after the update — or plan a migration off 3.9. The uv changelog recommends moving off 3.9 and 3.10 for exactly this reason: the upstream CPython builds for those versions still bundle pip releases that trust the affected intermediates.
If your team can't move off 3.9 this week, that's an honest constraint and worth writing down. The alternative is to run 3.9 builds through a path that doesn't depend on the bundled pip — a system-trust-store pip from your distribution, or uv for installs — and to note the gap in your own risk register rather than pretending the version floor covers you.
Safe bootstrap for uv
Woodruffw's post in the advisory thread gives the equivalent for uv:
UV_SYSTEM_CERTS=true uv self update
That tells uv, and only uv, to use your host operating system's root store instead of uv's own bundled roots for the length of that command. It assumes your system CA bundle is already current, for example through ca-certificates on Debian or Ubuntu. On macOS the system store updates with the OS; on Linux updating it is a deliberate package-manager step; on Windows it's Windows Update. If your host store is stale too, this flag just points uv at a different stale bundle.
If you installed uv through a package manager, uv self update isn't your path — use brew upgrade uv, apt upgrade uv, or whatever your distribution calls it. The UV_SYSTEM_CERTS variable doesn't upgrade Homebrew or apt or anything else; it only changes how uv's own TLS connections behave for that one command.
After updating the uv binary, you still have to upgrade pip and certifi in every existing virtual environment. Each environment carries its own copies, frozen at whatever versions were current when someone ran python -m venv. That's the detail people skip, and it's the one that leaves a build exposed.
Where the trust actually lives
When you open a TLS connection from Python, four different things can decide whether to trust the certificate. Updating one does nothing for the others.
The operating system trust store. Python's urllib and the ssl module normally use this. apt upgrade ca-certificates updates this layer. Libraries that link against OpenSSL directly may also use it.
The certifi package. The requests library uses certifi by default when it's available, calling certifi.where() to find the bundle. You can override it with REQUESTS_CA_BUNDLE, but without an override a requests.get() call isn't looking at your system store. Updating the OS certificates has no effect on it.
pip's own vendored bundle. pip carries a copy of certifi inside its wheel. According to the advisory, pip always loads CAs from that vendored bundle in addition to the system store, and there's no supported way to turn that off. REQUESTS_CA_BUNDLE and --cert only add authorities; they can't remove the ones pip already carries. A stale pip still trusts a compromised intermediate even when your system store and your project's certifi are current.
uv's embedded roots. uv bundles Mozilla-derived roots compiled into its binary, via webpki-root-certs. uv self update replaces the binary and its embedded roots together. UV_SYSTEM_CERTS tells uv to use the system store instead of the embedded one for a specific command.
These layers don't cascade. There's no automatic fallback from ssl to certifi when openssl_cafile is None. The string ssl.get_default_verify_paths().openssl_cafile tells you where OpenSSL would look; it doesn't tell you what your libraries actually use.
Back to the build from earlier. Say you update the base image's ca-certificates, run pip install --upgrade certifi during the Docker build, and then forget that the pinned FROM image still bundles a pip built before October 20. Your pip is now the only component in that image trusting the old chain, and it's the component that fetches every package you install. The layers you fixed don't protect the layer you missed.
Here's the same shape in a different spot. You update certifi in the project and your test suite goes green, because the tests install with uv pip install and hit uv's embedded roots. Meanwhile your CI pipeline still runs a legacy script calling pip install directly, and that pip is version 25.0.1 from a base image built in June. The script doesn't fail. Nothing looks different. But that pip install pulled your private wheel from your artifact repository over a chain an attacker could have forged. A green pipeline is not evidence about a layer it never touched.
Distribution defaults complicate this further. Debian patches its packaged pip to use the system CA store; Fedora may do something similar. REQUESTS_CA_BUNDLE can redirect requests at a file of your choosing. What matters is knowing which path your particular combination of distro, library and configuration actually takes — and writing it down somewhere your teammates can find.
Upgrading environments you already have
After updating pip and uv, walk every long-lived virtual environment. Each carries its own copy of pip. Run the hash-bootstrap inside each one, using that environment's own interpreter:
/path/to/venv/bin/python -m pip install "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
Then upgrade certifi inside each venv. If a lockfile or constraints file pins an older certifi or pip, change the pin. Deleting .venv and running uv sync replays whatever the lockfile says; it doesn't reach for the newer certifi on its own. The pin has to change.
uv 0.13.5 (October 21) updates the latest managed CPython builds for 3.11 and newer to include pip 26.2.2 with certifi 2026.10.19, preinstalled in the managed interpreter and bundled with ensurepip. After upgrading uv, run uv python upgrade to install those builds. Environments pinned to an older patch version don't switch automatically, per the release notes, so upgrade pip and certifi inside existing venvs explicitly.
For Python 3.10 and 3.9, upstream CPython builds from python.org still bundle pip releases that trust the affected intermediates. The uv changelog recommends migrating off both. The python.org security releases on October 21 — 3.15.1, 3.14.9 and 3.13.17 — ship the corrected pip and certifi.
The trap here is easy to fall into. You upgrade pip in one venv, run pip --version, see 26.2.2 and move on, while a second venv, the system Python and a Docker entrypoint script all still run the old version. Before you call it done, list every path your CI and deploy scripts actually invoke. The version you check at a terminal isn't necessarily the version your build uses at 3 a.m.
Container images and cached layers
In CI/CD, a base image pinned by SHA-256 digest gives your build one immutable filesystem snapshot, including whatever pip and certifi versions were bundled when the snapshot was taken:
FROM python:3.13.16-slim@sha256:<the-digest-you-pinned>
docker build --pull fetches that pinned digest if it isn't already cached locally, but it will never choose a different digest on its own. If the exact digest you pinned is already cached, --pull sees the matching hash and moves on. The only way to get a different base is to update the digest in the Dockerfile yourself. --no-cache skips the layer cache during the build; it doesn't change which base you pointed at. Those two flags get talked about as if they refresh everything, and they don't.
Pin by mutable tag instead — FROM python:3.13.16-slim — and --pull --no-cache will fetch the latest build behind that tag. But a mutable tag isn't a promise about which build you received, and even with a fresh base, a lockfile still pinning stale versions reinstalls them inside the new image. Rebuild, then look inside the result and read the versions out of the running container.
Checking what you have
Here's a decision path for checking whether an environment is safe. It's a sensible starting point, not a guarantee across every platform.
Figure 1: A BPMN workflow for auditing certificate bundles. The gateway checks whether certifi and pip are at or above the safe floor. If not, the upgrade path installs a trusted pip and updates certifi and uv. If at or above the floor, no change is needed at these checked package layers — but verify all applicable layers (system store, certifi, pip, uv) separately, and match against what your distribution and configuration actually use.
Run these checks in each active environment.
Check the pip version:
python -m pip --version
It must report 26.2.2 or later. Give the full venv path so you know which interpreter answered; a bare pip may point somewhere else entirely.
Check the certifi version:
python -m pip show certifi
It must be 2026.10.19 or later.
Check the uv version:
uv --version
It must be 0.13.4 or later.
Inspect the bundle locations:
import ssl, certifi
print("OpenSSL default CA file:", ssl.get_default_verify_paths().openssl_cafile)
print("certifi bundle:", certifi.where())
A path existing doesn't prove it's safe — a file can exist and still be stale. But if the certifi path points somewhere your upgrade didn't touch, that's your next investigation.
When you check a container, run these inside the running image rather than on your workstation. The workstation tells you about the workstation. The image tells you about production.
What's still messy, and the one thing to do next
This piece was written against the Python advisory, the uv 0.13.4 and 0.13.5 release notes, the pip changelog and the public disclosure page, checked live on October 26, 2026. No fleet-level testing was performed. Your operating system, container runtime, package manager, network configuration and pinned versions all affect the real outcome.
Cleaning up a stale trust store in production stays fiddly, and that's worth saying plainly. pip's vendored bundle can't be disabled. uv carries embedded roots. The hash-bootstrap needs Python 3.10 or later. System stores, package certifi and tool bundles are separate targets with no automatic coordination between them. No single command fixes all four.
If you do one thing this week, do this: open your production Dockerfile, read the FROM line, and find out which pip and certifi your running image actually loads. That's one file and about ten minutes. Everything else in this article — the hash-bootstrap, the uv flag, the per-venv upgrades — follows from what you find there. If the answer is a pinned digest from before October 20, you know exactly which layer to refresh and you don't have to guess about the rest.