Key Takeaways
- A private PyPI repository answers where packages come from, not whether they are safe to install.
- Adding –extra-index-url instead of pointing –index-url at one index reopens the hole the private repository was built to close.
- A mirror or cache serves whatever upstream published, malicious versions included.
- Linking an installable package to verified source takes an explicit verification step, not a config change.
Standing up a private PyPI repository looks like a tidy piece of work. Spin up devpi or Artifactory, put the internal URL in the onboarding docs, close the ticket. No more developers pulling straight from public PyPI. An auditor gets an answer. Nightmare over.
Except the private repository is just mirroring public PyPI. The origin question now has an answer, caching works, there’s a policy chokepoint. But whether any given package is trustworthy hasn’t been addressed. It’s been relocated.
That’s the part worth working through before picking an architecture. The four common approaches to a private PyPI repository differ enormously in what they verify, and verification is absent from the basic mirror and proxy models.
What a Private PyPI Repository Actually Solves
Four problems, and they’re real.
Availability, so a public PyPI outage doesn’t take your deploys down with it. Bandwidth and speed, because pulling the same wheels a thousand times a day from the internet is wasteful. Internal package distribution, so private libraries have somewhere to live besides a Git URL buried in requirements.txt. And a policy chokepoint, which is the one that actually matters for security.
What it doesn’t solve on its own is package integrity. A pull-through cache fetches from upstream, stores the result, and serves it. If upstream served malware for three hours, the cache preserves that malware faithfully and keeps serving it to everyone afterward, with excellent latency.
A private PyPI repository changes where your packages come from. It doesn’t change what they are. Treating the repository itself as a security control is a common architectural mistake in this space.
The rate of malicious packages landing on public registries has climbed year after year, and a mirror sitting downstream of that growth inherits all of it by design. There’s nothing in the mirroring mechanism that filters for intent.
The extra-index-url Mistake That Undoes Everything
pip has two flags that look interchangeable. They aren’t, and the distinction is easy to miss – right up until the day someone exploits it.
# Replaces the default index. Only your repository is consulted.
pip install –index-url https://pypi.internal.example.com/simple/ mypackage
# Adds a second index. pip searches both, with no priority between them.
pip install –extra-index-url https://pypi.internal.example.com/simple/ mypackage
–extra-index-url usually shows up because –index-url alone broke something first. A package wasn’t found, the build failed, and adding a second index made the error go away.
Here’s why it matters. pip has no concept of which index a package is supposed to come from. Its own documentation is blunt about this: there is no priority among the locations it searches, so all of them get checked and the best match for the requirement wins, which in practice means the highest compatible version. If an attacker publishes internal-billing-utils version 99.0.0 to public PyPI, pip will take it over your internal 2.4.1 without hesitating.
That’s dependency confusion, and –extra-index-url is how it gets in.
Check what your build agents are actually configured with, not what the docs say. PIP_INDEX_URL and PIP_EXTRA_INDEX_URL in the environment silently override pip.conf, and a base image inherited from another team is a common way one sneaks in unnoticed.
python -m pip config debug
pip config get global.index-url
pip config debug shows which file or variable each setting came from, which is usually faster than guessing. Check the environment for both PIP variables while you’re in there.
The fix is to use –index-url exclusively and make your private repository the only index pip knows about, with internal and upstream packages both served through it. PEP 708 proposed repository metadata that would let indexes signal these relationships and take the guesswork out of merging them, but after three years in provisional status it was rejected in 2026 because the required implementations never landed. Single-index discipline is what actually holds today.
Four Ways to Run a Private PyPI Repository
The options divide by what they do with upstream packages, not by which product name is on the box.
| Approach | Upstream handling | Verifies artifacts | Ops burden |
| Full mirror | Copies all of PyPI locally | No | High, storage grows continuously |
| Pull-through proxy | Fetches and caches on demand | No | Low to moderate |
| Curated allowlist | Only approved packages resolve | Only what a human checked | High, ongoing review |
| Verified managed registry (e.g., SecureChain) | Screens and rebuilds before serving | Yes | Low, outsourced |
Full mirroring with bandersnatch is the traditional answer and by far the most storage-hungry. All of PyPI runs about 44 TB of release files as of this writing, according to PyPI’s stats page, and that number only grows. Storage is the easy part – keeping the sync healthy across that much data is the actual cost.
Pull-through proxies are the common choice. devpi, proxpi, Nexus Repository, Artifactory, AWS CodeArtifact, Azure Artifacts, Google Artifact Registry: different products, same architecture underneath. The proxy and cache function solves availability and caching well, and it does nothing to establish that an upstream artifact is trustworthy, because that was never its job.
Curated allowlists are one of the stronger controls a team can run in-house without building a full verification pipeline. They’re also the one that quietly fails, because the review queue becomes somebody’s least favorite task and approvals start happening in batches to unblock a release.
Verified managed registries are built around verification rather than availability. SecureChain for Python works this way. Packages are rebuilt from source and screened before they’re ever available to install, not cached after the fact and scanned later.
Before picking an approach, run pip download -r requirements.txt and count the distinct packages that come back, transitives included. Sizing a curated allowlist against your direct dependency count alone can understate the review load badly, because the transitive tree is where the volume lives.
Mirroring Is Not Verification
There are three distinct questions a Python packaging setup can answer, and separating them matters because products blur the line.
Did this artifact arrive intact? Hashes answer that, and pip supports it well.
Did this artifact come from where it claims to come from? Provenance and signing answer that, partially, where publishers participate.
Does this artifact match the source code it claims to be built from? Almost nothing in the default toolchain answers that.
The third question is the one that catches tampered wheels. A wheel is a build output, and nothing in the packaging system requires it to correspond to the repository the package links to. A maintainer account compromise can produce a wheel that hashes consistently, installs cleanly, and contains code the repository has never seen.
pip-compile –generate-hashes requirements.in
pip install –require-hashes -r requirements.txt
Turn on hash-checking mode regardless of what else you do. It forces every dependency, transitives included, to be pinned with a hash, which stops silent substitution and makes installs repeatable. It also confirms nothing about whether the pinned artifact was ever legitimate. It locks in whatever got resolved the first time.
Closing the third question means establishing a trustworthy link between source and installable artifact, which in practice means rebuilding from verified source in a controlled environment and installing that build rather than an opaque upstream binary. That’s deliberate engineering work, not a configuration setting.
Trusted Publishing and What durabletask Exposed
Trusted Publishing replaces long-lived PyPI API tokens with short-lived credentials issued over OIDC to one specific CI workflow. The token it mints expires within 15 minutes, so there’s no long-lived credential sitting in a secrets store waiting to be stolen.
Microsoft’s durabletask SDK didn’t have it turned on when the TeamPCP campaign pushed three malicious versions to PyPI on May 19, 2026. Versions 1.4.1 through 1.4.3 went out inside a 35-minute window, uploaded straight to PyPI with a stolen long-lived API token that had been sitting in the project’s GitHub Actions secrets. No matching tags, no publishing workflow run, nothing in the repository history to explain how the releases got there. It’s exactly the token-theft path Trusted Publishing exists to close.
Telnyx and LiteLLM had already learned that lesson about eight weeks earlier and the hard way. Both had malicious releases pushed to PyPI in March 2026 with no matching tags in their repositories, and both moved to Trusted Publishing afterward, which PyPI’s own incident report documents. durabletask still hadn’t adopted it when TeamPCP came back around in May. As of its most recent release at the time of writing, 1.9.0 from July 30, 2026, PyPI still shows the files as published without Trusted Publishing.
That gap is the more useful part of the story, more than any one incident on its own. The fix has existed for years. Two high-profile compromises pushed two projects to adopt it. Weeks later the same campaign compromised a third project through another stolen publishing credential.
Trusted Publishing is genuinely effective against the credential-theft path, and it has two limits worth stating plainly. It protects packages you publish, not packages you consume, so turning it on does nothing about the dependencies already sitting in your tree. And it depends on every maintainer upstream of you making the same choice, which is not something you can count on.
| Control | Protects | Does not protect |
| Trusted Publishing | Packages you publish | Anything you install |
| Hash pinning | Against substitution after first resolve | Against a compromised first resolve |
| Private repository | Controlled package source, availability, policy | Artifact integrity |
| Registry-level screening | Artifacts before they resolve | Code you wrote yourself |
If your organization publishes packages, enable Trusted Publishing this week. It’s a short configuration change that closes off an entire attack path.
Hardening the Client Side
Repository architecture gets the attention. Client configuration is where enforcement actually lives, and it’s usually inconsistent.
Four things are worth checking across every environment where pip runs. First, a single index-url in a system-level pip.conf, with no extra-index-url anywhere. Second, PIP_INDEX_URL and PIP_EXTRA_INDEX_URL unset in base images and CI configuration. Third, hash-checking mode enabled in CI, even if developers skip it locally. Fourth, network egress from build agents restricted so a misconfigured client can’t reach public PyPI at all.
That last one is what turns policy into enforcement. Configuration drifts, base images get inherited, and someone always passes a flag to unblock a deploy. An egress policy that lets build agents reach only the approved private repository catches all of that without depending on anyone’s discipline. Blocking a single hostname isn’t enough, since the index and the file host it points at are separate.
Choosing an Approach
Match the approach to what’s actually being solved, not to what’s fastest to stand up.
A pull-through proxy is the right call when the goal is availability and caching. It’s good infrastructure, and it stops being useful the moment it gets treated as a security boundary. A curated allowlist adds a policy chokepoint an audit can point to, as long as someone keeps the review queue from turning into a rubber stamp. Full mirroring rarely earns its storage cost outside of air-gapped environments. None of the three inherently guarantees artifact integrity, and a curated allowlist provides only as much assurance as its review process actually establishes.
If artifact integrity is genuinely in scope, not just availability or an audit trail, what you need is a verification layer that rebuilds packages from trusted source and screens them before they resolve. A verified managed registry provides that without you having to build and operate the pipeline, which in-house is a multi-quarter engineering commitment rather than a sprint.
Where SecureChain for Python Fits
Go back to the three questions from earlier: intact, from where it claims, matches its source. Most private-repository setups stop at the first. SecureChain is built to answer all three. Every package is rebuilt from source in hardened environments and screened before it’s ever available to install, with SLSA Level 3 provenance, SBOM and VEX data alongside it, and patching that continues even after a maintainer walks away.
If you want to see how that holds up against your own dependency tree, ask our experts about SecureChain for Python and the right approach for your stack.
FAQ
What is a private PyPI repository?
An internally hosted Python package index that serves packages to your developers and build systems instead of pointing them at public PyPI directly. It can mirror upstream, proxy and cache on demand, serve only an approved set, or screen packages before serving them.
Does a private PyPI repository make installs safer?
Only if it verifies packages. A mirror or pull-through cache serves whatever upstream published, malicious versions included. The security benefit comes from screening or curation layered on top, not from the repository itself.
What is the difference between index-url and extra-index-url?
–index-url replaces the default index, so only your repository is consulted. –extra-index-url adds another index, and pip searches all configured indexes without ranking them, which is how a malicious public package can win resolution and hand you a dependency confusion attack.
How do I stop dependency confusion with pip?
Use –index-url exclusively, serve internal and upstream packages through that single index, unset PIP_EXTRA_INDEX_URL everywhere, and restrict egress from build agents so only the approved repository is reachable.
Should I run a full PyPI mirror?
Rarely. A complete mirror means carrying roughly 44 TB and growing, and it provides no verification benefit over a pull-through proxy. Full mirroring makes the most sense for air-gapped environments.
What is Trusted Publishing on PyPI?
An OIDC-based publishing mechanism that issues short-lived credentials to one specific CI workflow instead of relying on long-lived API tokens. It protects packages you publish. It does nothing about packages you install.




