Skip to content

How to Protect Your Python Projects from PyPI Supply Chain Attacks: 5 Proven Strategies

I woke up to 47,000 developers getting their API keys stolen. The LiteLLM package—something I’d used just weeks before—had been compromised. Attackers with stolen credentials uploaded malicious versions 1.82.7 and 1.82.8 directly to PyPI, bypassing CI/CD protections entirely.

In just 46 minutes, the damage was done.

That morning, I realized my pip install commands were blind trust operations. I was pulling code directly from strangers’ keystrokes to my production servers. No delay. No verification. No safety net.

Here’s what I learned about protecting Python projects from PyPI supply chain attacks—and the 5 strategies I now use to sleep better at night.

The Problem: PyPI’s Immediate Trust Model

When I run pip install requests, PyPI gives me the latest code immediately. No human verification. No waiting period. No safety checks.

The attack timeline
[Attacker] --(stolen credentials)--> [PyPI Upload]
|
v
[46 minutes later: Takedown] [47,000 users infected]
|
v
[API keys stolen]

The assumption behind this model: maintainers are always trustworthy and their credentials are never compromised.

The LiteLLM attack proved both assumptions can fail simultaneously. Even popular packages with millions of downloads have single points of failure.

As one commenter on the incident put it:

“In theory, requests could decide to go rogue after having a bad Monday morning, and push an update that immediately infects 3000 users per minute.”

I needed to stop being that trusting.

Strategy 1: Private PyPI Mirror with Lead Time

The first thing I did was set up a private mirror that delays propagation of new upstream versions by 24-72 hours. This gives security researchers time to detect malicious packages before they reach my infrastructure.

I chose devpi because it’s Python-native and straightforward to configure:

Setting up devpi private mirror
# Install devpi components
pip install devpi-server devpi-web devpi-client
# Initialize the server
devpi-init
# Start server (in production, use systemd/supervisor)
devpi-server --host 0.0.0.0 --port 3141
# Create user and index with delayed propagation
devpi use http://localhost:3141
devpi login root --password ""
devpi user -m root password=yourpassword
devpi login root --password=yourpassword
devpi index -c devbases root/devbases bases=root/pypi volatile=False
# Configure pip to use private mirror
pip config set global.index-url http://your-server:3141/root/devbases/+simple/
pip config set global.trusted-host your-server

The key insight: volatile=False means packages don’t update automatically from upstream. I control when new versions enter my environment.

For enterprise setups, I’ve also seen teams use Artifactory or Nexus with similar delay configurations.

Strategy 2: Exact Version Pinning with Hash Verification

Version pinning alone isn’t enough. Pinned versions still pull fresh from PyPI each build unless you vendor or mirror them.

I started using cryptographic hashes in my requirements files:

requirements.txt with hash pinning
# Core dependencies with hash verification
litellm==1.82.6 \
--hash=sha256:a1b2c3d4e5f6... \
--hash=sha256:b2c3d4e5f6g7...
requests==2.31.0 \
--hash=sha256:c3d4e5f6g7h8... \
--hash=sha256:d4e5f6g7h8i9...
urllib3==2.0.7 \
--hash=sha256:e5f6g7h8i9j0...

To generate these hashes, I use:

Generating package hashes
pip download litellm==1.82.6
pip hash litellm-1.82.6.tar.gz

Now if someone tries to slip a different tarball with the same version number past me, pip will refuse to install it. The hash won’t match.

This was the single most impactful change I made. It took about 2 hours to convert all my projects, and the peace of mind was worth every minute.

Strategy 3: Manual Curation for Critical Dependencies

For business-critical dependencies—anything that handles authentication, payments, or sensitive data—I require manual approval before new versions enter my environment.

I created a simple approval workflow:

.github/workflows/dependency-review.yml
name: Dependency Review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for new dependency versions
run: |
# Compare against approved versions list
python scripts/check_approved_versions.py

The check_approved_versions.py script maintains a whitelist:

scripts/check_approved_versions.py
#!/usr/bin/env python3
"""Check dependencies against approved versions list."""
import sys
from pathlib import Path
APPROVED = {
"litellm": ["1.82.6"],
"requests": ["2.31.0", "2.32.0"],
"urllib3": ["2.0.7"],
}
def check_requirements(req_file: str = "requirements.txt") -> bool:
"""Verify all dependencies are in approved list."""
with open(req_file) as f:
for line in f:
if line.strip() and not line.startswith("#"):
pkg, version = parse_requirement(line)
if pkg not in APPROVED or version not in APPROVED[pkg]:
print(f"❌ Unapproved: {pkg}=={version}")
return False
return True
def parse_requirement(line: str) -> tuple[str, str]:
"""Extract package name and version from requirement line."""
# Handle hash pinning: pkg==1.0.0 \ --hash=sha256:abc...
line = line.split("\\")[0].strip()
if "==" in line:
pkg, version = line.split("==", 1)
return pkg.strip(), version.strip()
return line.strip(), "latest"
if __name__ == "__main__":
sys.exit(0 if check_requirements() else 1)

This doesn’t scale for every dependency, but for the 5-10 critical packages in any project, it’s manageable and adds a human verification layer.

Strategy 4: Vendoring Critical Dependencies

For maximum paranoia—and maximum control—I vendor the most critical dependencies directly into my repository.

Yes, this increases repository size. Yes, it requires manual updates. But for that one package that handles all my API keys? Worth it.

Vendoring Python packages
# Create vendor directory
mkdir -p vendor
# Download packages to vendor directory
pip download -d vendor/ litellm==1.82.6 requests==2.31.0
# Install from vendored packages (no network access needed)
pip install --no-index --find-links=vendor/ litellm requests

The --no-index flag is crucial here. It tells pip: “Don’t even try PyPI. Only use what’s in this local directory.”

This means even if PyPI goes completely rogue, my builds still work from vendored packages.

Strategy 5: Security Scanning in Isolated Environments

I scan all dependencies in an isolated environment before they reach production. This catches known vulnerabilities even if they’re not supply chain attacks.

I use both pip-audit and safety for defense in depth:

scan_dependencies.py
#!/usr/bin/env python3
"""Security scan for Python dependencies"""
import subprocess
import sys
from pathlib import Path
def run_scan(tool: str, requirements_file: str = "requirements.txt") -> bool:
"""Run security scan and return True if no issues found."""
try:
if tool == "pip-audit":
result = subprocess.run(
["pip-audit", "-r", requirements_file, "--format", "json"],
capture_output=True,
text=True
)
elif tool == "safety":
result = subprocess.run(
["safety", "check", "-r", requirements_file, "--json"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"ERROR: {tool} found vulnerabilities:")
print(result.stdout)
return False
print(f"OK: {tool} scan passed")
return True
except FileNotFoundError:
print(f"WARN: {tool} not installed, skipping")
return True
def main():
requirements = Path("requirements.txt")
if not requirements.exists():
print("ERROR: requirements.txt not found")
sys.exit(1)
all_passed = all([
run_scan("pip-audit"),
run_scan("safety")
])
sys.exit(0 if all_passed else 1)
if __name__ == "__main__":
main()

And I automated this in CI:

.github/workflows/dependency-security.yml
name: Dependency Security
on:
push:
paths:
- 'requirements.txt'
- 'requirements-lock.txt'
pull_request:
schedule:
- cron: '0 6 * * *' # Daily scan at 6 AM UTC
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install security tools
run: pip install pip-audit safety
- name: Run pip-audit
run: pip-audit -r requirements.txt --format json --output pip-audit.json
continue-on-error: true
- name: Run safety
run: safety check -r requirements.txt --json --output safety.json
continue-on-error: true
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: security-reports
path: |
pip-audit.json
safety.json
- name: Fail on vulnerabilities
run: |
if [ -s pip-audit.json ] || [ -s safety.json ]; then
echo "Vulnerabilities found, check artifacts"
exit 1
fi

The continue-on-error: true on the scan steps means I always get the artifacts, even if there are vulnerabilities. Then the final step decides whether to fail the build.

What I Got Wrong Initially

Mistake 1: “We pin versions, we’re safe”

I thought pinning litellm==1.82.6 was enough. It wasn’t. Pinned versions still pull fresh from PyPI each build unless vendored or mirrored. Hash pinning was the missing piece.

Mistake 2: “Our CI/CD is secure”

The LiteLLM attackers had stolen credentials and uploaded directly to PyPI, bypassing CI/CD entirely. My GitHub Actions workflows were protecting the wrong attack vector.

Mistake 3: “We trust our dependencies”

Even trusted maintainers can be compromised. The requests library maintainer is human. Humans have bad days. Humans click phishing links. I needed to stop assuming trust.

Mistake 4: “We’ll catch it quickly”

The LiteLLM takedown took 46 minutes. That’s fast by incident response standards. But 47,000 users were already affected. Prevention beats detection.

The Architecture I Use Now

Defense-in-depth architecture
┌─────────────────────────────────────────────────────────────┐
│ Developer Machine │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ requirements.txt (with hashes) │ │
│ │ litellm==1.82.6 --hash=sha256:abc123... │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────┐
│ Private PyPI Mirror │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 24-72 hour delay on new versions │ │
│ │ Manual curation for critical packages │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────┐
│ Security Scanning │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ pip-audit │ │ safety │ │
│ │ (known vulns) │ │ (known vulns) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────┐
│ Production │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Verified, scanned, delayed dependencies only │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Immediate Actions You Can Take Today

  1. Add hash verification to all requirements.txt files (start with your most critical project)
  2. Set up dependency scanning in CI/CD pipelines (pip-audit + safety)
  3. Evaluate private PyPI mirrors (devpi for small teams, Artifactory/Nexus for enterprise)
  4. Create an approved dependencies list for critical packages
  5. Subscribe to security advisories for your top 10 dependencies

The cost of protection is a few hours of setup. The cost of a successful supply chain attack includes incident response, credential rotation, potential data breach, and reputation damage.

Start with hash pinning. It’s the highest-impact, lowest-effort change you can make today.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments