Skip to content

How to Pin Hashes in requirements.txt for Python Security?

Problem

I ran pip install -r requirements.txt in production. The build succeeded. But I had no idea if the packages I downloaded were the same ones the developer tested. PyPI could have served me anything.

Here’s what a typical requirements.txt looks like:

Typical requirements.txt (no protection)
requests==2.31.0
urllib3==2.0.7
certifi==2023.7.22

This file pins versions but provides no integrity verification. If an attacker compromises PyPI or a package maintainer’s credentials, they can serve malicious code with the same version number.

Why this is dangerous

Supply chain attacks on PyPI have increased significantly. Attackers use several techniques:

Supply chain attack vectors
1. Typosquatting
- Upload "reqeusts" (typo of "requests")
- Users accidentally install malicious package
2. Dependency confusion
- Attacker registers internal package name on PyPI
- pip installs public malicious version instead of private
3. Compromised maintainer
- Attacker gains access to maintainer's PyPI credentials
- Uploads malicious version of legitimate package
- Same version number, different code

The critical insight from a Reddit discussion on PyPI dependency hygiene:

“CVE databases lag supply chain attacks by 24-72 hours. Your fastest signal is: this package hash changed when I didn’t push anything.”

Without hash pinning, pip blindly trusts whatever PyPI serves. You need cryptographic verification.

What is hash-checking mode?

Pip has a built-in hash-checking mode. When enabled, pip verifies that every downloaded package matches a known-good cryptographic hash before installing.

How hash verification works
1. You specify expected hash in requirements.txt
2. pip downloads the package
3. pip computes SHA256 hash of downloaded file
4. pip compares computed hash to expected hash
5. If match: install proceeds
6. If mismatch: installation fails immediately

This protects against:

  • Packages modified in transit
  • Malicious packages uploaded to PyPI after compromise
  • Typosquatted packages with wrong hash

How to pin hashes manually

Step 1: Get the package hash

Download package and compute hash
pip download requests==2.31.0
pip hash requests-2.31.0-py3-none-any.whl

Output:

pip hash output
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f

Step 2: Add hash to requirements.txt

requirements.txt with hash pinning
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1

Note: Multiple hashes are allowed for packages with multiple distribution formats (wheel, source tarball). Pip will accept any matching hash.

Step 3: Install with hash verification

Install with hash verification
pip install --require-hashes -r requirements.txt

The --require-hashes flag forces pip to verify hashes. If any package lacks a hash, pip refuses to install it.

I tried it and got an error

I added hashes to my direct dependencies but got this error:

Hash verification error
ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements.
urllib3 is missing hashes.
certifi is missing hashes.

The problem: I only pinned hashes for my direct dependencies. But requests depends on urllib3, certifi, and other packages. Pip’s hash-checking mode requires hashes for ALL dependencies, including transitive ones.

The real solution: pip-compile

Manually tracking hashes for every transitive dependency is impractical. A single package update might require updating dozens of hashes. Use pip-tools instead.

Step 1: Install pip-tools

Install pip-tools
pip install pip-tools

Step 2: Create requirements.in

Create a requirements.in file with only your direct dependencies:

requirements.in (direct dependencies only)
requests==2.31.0
flask==3.0.0

Step 3: Compile with hashes

Generate requirements.txt with all hashes
pip-compile --generate-hashes requirements.in

Output:

Generated requirements.txt with hashes
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --generate-hashes requirements.in
#
certifi==2023.7.22 \
--hash=sha256:539cc1d13202e33ca466e88b2807e4f3fef0d4189ded1f13787b6e0e8e9e18f9 \
--hash=sha256:539cc1d13202e33ca466e88b2807e4f3fef0d4189ded1f13787b6e0e8e9e18f9
# via requests
charset-normalizer==3.3.2 \
--hash=sha256:8c4d2d03b668a7c5c6c6c9c6c9c6c9c6c9c6c9c6c9c6c9c6c9c6c9c6c9c6c9c6
# via requests
flask==3.0.0 \
--hash=sha256:7736e6c57c3c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5
# via -r requirements.in
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
# via -r requirements.in
urllib3==2.0.7 \
--hash=sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9f55dd6f2dc91b4def \
--hash=sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e
# via requests

pip-compile resolves all transitive dependencies and adds hashes for every package. The # via comments show why each package is included.

Step 4: Install

Install with full verification
pip install --require-hashes -r requirements.txt

Now pip verifies every single package against its expected hash.

What happens when a hash mismatch occurs

If PyPI serves a different file than expected:

Hash mismatch error
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE.
requests==2.31.0 from https://files.pythonhosted.org/packages/.../requests-2.31.0-py3-none-any.whl:
Expected sha256 58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f
Got a1b2c3d4e5f6... (different hash)

The installation fails immediately. The malicious or corrupted package never reaches your environment.

CI/CD integration

Hash verification should be mandatory in CI. Add it to your pipeline:

GitHub Actions workflow with hash verification
name: Install Dependencies
on: [push, pull_request]
jobs:
install:
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 dependencies with hash verification
run: pip install --require-hashes -r requirements.txt
- name: Run tests
run: pytest

If any package hash changes unexpectedly, the CI fails. Vulnerable code cannot deploy.

Combining with pip-audit for defense in depth

Hash pinning protects against tampering. pip-audit protects against known vulnerabilities. Use both:

Complete security pipeline
name: Security Check
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify package integrity (hashes)
run: pip install --require-hashes -r requirements.txt
- name: Scan for known vulnerabilities (CVEs)
uses: pypa/[email protected]
with:
inputs: requirements.txt
Defense layers
Layer 1: Hash verification
- Protects against tampering
- Detects unexpected changes
- Fails CI immediately on mismatch
Layer 2: CVE scanning (pip-audit)
- Detects known vulnerabilities
- Reports fix versions
- Updates daily with new CVEs

Common mistakes

Mistake 1: Only pinning direct dependencies

WRONG: Missing transitive dependency hashes
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f
# Missing: urllib3, certifi, charset-normalizer, idna

pip-compile solves this automatically. It resolves and hashes all transitive dependencies.

Mistake 2: Forgetting —require-hashes

WRONG: Hashes specified but not enforced
pip install -r requirements.txt # Ignores hashes!

Without --require-hashes, pip uses hashes as hints but does not enforce them. Always use:

CORRECT: Enforce hash verification
pip install --require-hashes -r requirements.txt

Mistake 3: Not updating hashes on version changes

When you want to update a package:

WRONG: Version change without hash update
# Just change the version number
requests==2.32.0 \
--hash=sha256:58cd2187... # Old hash for 2.31.0!

This fails with hash mismatch. Always regenerate with pip-compile:

CORRECT: Regenerate hashes
# Update requirements.in
echo "requests==2.32.0" > requirements.in
# Regenerate requirements.txt
pip-compile --generate-hashes requirements.in

Mistake 4: Relying solely on hashes

Hash pinning protects against tampering but not against vulnerabilities in legitimate code. A compromised maintainer could publish malicious code with the correct hash.

Security layers comparison
Threat | Hash Pinning | pip-audit
-------------------------------|--------------|----------
Package tampering in transit | YES | NO
Package tampering at PyPI | YES | NO
Known CVEs in packages | NO | YES
Malicious code from maintainer | NO | NO (if no CVE)

Combine hash pinning with:

  • pip-audit for CVE scanning
  • Dependency review for new packages
  • Maintainer reputation checks

Scheduled hash monitoring

A Reddit comment highlighted an important practice:

“Hash the installed package metadata at deploy time and run pip-audit on a cron. CVE databases lag supply chain attacks by 24-72 hours. Your fastest signal is: this package hash changed when I didn’t push anything.”

Add monitoring to detect unexpected changes:

Daily hash check cron job
#!/bin/bash
# /usr/local/bin/check-requirements-hash.sh
STORED_HASH="/var/cache/requirements.hash"
CURRENT_HASH=$(sha256sum requirements.txt | awk '{print $1}')
if [ -f "$STORED_HASH" ]; then
if [ "$CURRENT_HASH" != "$(cat $STORED_HASH)" ]; then
echo "ALERT: requirements.txt hash changed unexpectedly!"
# Send notification
exit 1
fi
fi
echo "$CURRENT_HASH" > "$STORED_HASH"
Cron configuration
# /etc/cron.d/requirements-monitor
0 6 * * * deploy /usr/local/bin/check-requirements-hash.sh

This catches the scenario where someone modifies requirements.txt without going through proper channels.

Quick reference

Hash pinning quick reference
# Install pip-tools
pip install pip-tools
# Create requirements.in (direct dependencies only)
echo "requests==2.31.0" > requirements.in
echo "flask==3.0.0" >> requirements.in
# Generate requirements.txt with all hashes
pip-compile --generate-hashes requirements.in
# Install with hash verification
pip install --require-hashes -r requirements.txt
# When updating a package:
# 1. Edit requirements.in
# 2. Run pip-compile --generate-hashes requirements.in
# 3. Commit both files
# Compute hash for a downloaded wheel
pip download requests==2.31.0
pip hash requests-2.31.0-py3-none-any.whl

Summary

Hash pinning in requirements.txt provides cryptographic verification that every installed package matches known-good values. This protects against supply chain attacks where PyPI or maintainer credentials are compromised.

Key points:

  • Use --require-hashes to force hash verification
  • Use pip-compile to automatically generate hashes for all dependencies
  • Integrate hash verification into CI/CD
  • Combine with pip-audit for defense in depth
  • Monitor for unexpected hash changes

The 24-72 hour lag between a supply chain attack and CVE publication means hash verification is often your fastest detection mechanism. If a package hash changes when you didn’t update requirements.txt, something is wrong.

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