A version pin says which release you want. It doesn't say which file you get. If an index, a mirror or a proxy serves a different file under the same name and version, requests==2.32.3 installs it without complaint. pip's hash-checking mode closes that gap: you write the expected SHA-256 of every file into the requirements file, and pip refuses anything that doesn't match.
It works, and it's been in pip since version 8.0. But it has edges that the name hides, and pip 26.2 (29 July) added a flag that switches part of it off. This guide is for Python teams that build containers, run CI or ship deployable environments. It covers what hash-checking mode enforces, how to generate hashes, what changed in pip 26.1 and 26.2, the build-dependency gap that pip's own source marks as unfinished, and an offline lab and audit script you can run.
The short answer
Run pip install --require-hashes -r requirements.txt with a file in which every requirement, including every transitive dependency, is pinned and carries a --hash=sha256: value. Generate it with pip-compile --generate-hashes, uv pip compile --generate-hashes, or pip hash for single files. pip then rejects any download whose hash doesn't match and any dependency missing from the file. Two gaps remain. Build dependencies of source distributions are installed without hash checks, even if you list them in --build-constraint, and pip 26.2's --no-require-hashes lets unhashed lines through. Add --only-binary :all: to close the first, and treat the second as a review flag.
What hash-checking mode enforces
Per pip's secure installs documentation, the mode turns on as soon as any requirement has a --hash, or when you pass --require-hashes. Once it's on, it's all or nothing:
- Every requirement needs a hash. A partly hashed file is treated as an error, because one unhashed line is enough to slip code in.
- Every dependency needs a hash too. If a package pulls in something not listed in the file, the install fails and pip prints the line you'd need to add.
- Requirements must be pinned with
==, or be a URL or file path. - Weak algorithms don't count. MD5, SHA-1 and SHA-224 are excluded. SHA-256 is the recommendation.
- The index's own hashes don't count. PyPI puts a SHA-256 in each download URL, and pip checks it against corruption, but a hash that comes from the server can't protect you from the server.
What it protects against is substitution: a compromised mirror or proxy, a poisoned cache, or an index that starts serving a different file for a version you already approved. It doesn't judge whether the version you hashed is safe. If the malicious release is the one you locked, the hash faithfully protects it. Hashes fix "is this the file I reviewed", not "should I have trusted it".
Generating the hashes
You rarely write hashes by hand. The common generators:
# pip-tools
pip-compile --generate-hashes requirements.in
# uv
uv pip compile --generate-hashes requirements.in -o requirements.txt
# a single local file
python -m pip hash dist/mypkg-1.0.0-py3-none-any.whl
A package often publishes several files per version: wheels for different platforms and a source distribution. The generators list a hash for each, so the same file works on every platform. pip only needs one of them to match the file it picks.
pip is also moving toward the standard lock file format, pylock.toml. pip 26.1 (26 April) added experimental support for installing from one with -r pylock.toml, and the experimental pip lock command writes one. Both still carry the experimental label in pip's documentation.
Two changes in pip 26.1 and 26.2
--no-require-hashes (26.2). Until now, one hash in a file forced hashes everywhere, which made it impossible to combine hashed packages with a local directory or a Git requirement. The new flag stops that automatic switch: lines with hashes are still checked, and lines without them install unchecked. It solves a real problem for monorepos. It also means a file can now carry hashes and still not be fully protected, so seeing --hash in a lock file is no longer proof the mode is on.
Build constraints (26.2). Constraints files, including PIP_CONSTRAINT, no longer apply inside the isolated environments pip creates to build source distributions. The replacement is --build-constraint. As the lab shows, it pins versions but doesn't verify hashes.
We also noticed a documentation gap. The docs say hashed requirements must be pinned with ==, but pip 26.2.1 installed greet>=1.0 --hash=sha256:... without error in our lab. The source explains it: when a requirement carries hashes, pip rewrites it to the exact version it resolved before the pin check runs. A code comment ties this to hashes supplied through constraints, which pip 26.1 started allowing for unpinned requirements. The hash still restricts which file installs, so this isn't a hole. Pin anyway: when a new release appears, an unpinned line fails with a confusing hash mismatch rather than a clear message.
The build-dependency gap
When pip installs a source distribution, it creates an isolated environment, installs the build backend the package names in pyproject.toml (setuptools, hatchling, maturin and so on), and runs it. Those build dependencies don't go through hash checking. pip's own source, in the 26.2.1 build environment installer, passes require_hashes=False, with a TODO noting that "hash-checking should be extended to build deps" and that doing so was deferred because it would be a breaking change.
So a fully hashed requirements file that includes one sdist still downloads and runs a build backend nobody hashed. The backend runs arbitrary code by design. That's exactly the substitution the mode exists to stop, at the point where code executes.
There are two ways to close it. --only-binary :all: refuses source distributions, so nothing gets built. That's the right default for deployment images, and it fails loudly when a wheel is missing. If you must build from source, install the build dependencies first from their own hashed file, then build with --no-build-isolation so pip uses what you installed instead of fetching more.
A lab you can run offline
This script builds tiny local packages: greet, which depends on colorlib, a minimal build backend called tinybackend, and an sdist called buildme that needs it. It uses only the standard library and makes no network calls:
"""Build tiny local wheels and one sdist for the lab. No network, no third-party tools."""
import base64, hashlib, io, pathlib, sys, tarfile, zipfile
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "dists")
out.mkdir(exist_ok=True)
def wheel(name, version, files, requires=(), tag="py3-none-any"):
dist = f"{name}-{version}.dist-info"
meta = f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
meta += "".join(f"Requires-Dist: {r}\n" for r in requires)
files = dict(files)
files[f"{dist}/METADATA"] = meta
files[f"{dist}/WHEEL"] = f"Wheel-Version: 1.0\nGenerator: lab\nRoot-Is-Purelib: true\nTag: {tag}\n"
record = []
for path, body in files.items():
digest = base64.urlsafe_b64encode(hashlib.sha256(body.encode()).digest()).rstrip(b"=").decode()
record.append(f"{path},sha256={digest},{len(body.encode())}")
record.append(f"{dist}/RECORD,,")
files[f"{dist}/RECORD"] = "\n".join(record) + "\n"
path = out / f"{name}-{version}-{tag}.whl"
with zipfile.ZipFile(path, "w") as z:
for p, body in files.items():
z.writestr(zipfile.ZipInfo(p, (2026, 1, 1, 0, 0, 0)), body)
return path
wheel("colorlib", "2.0.0", {"colorlib.py": "def red(s):\n return '\\033[31m' + s + '\\033[0m'\n"})
wheel("greet", "1.0.0", {"greet.py": "import colorlib\n\ndef hello():\n return colorlib.red('hello')\n"},
requires=["colorlib==2.0.0"])
# A minimal PEP 517 build backend, shipped as a wheel, used to build the sdist below.
backend = '''import base64, hashlib, os, zipfile
def get_requires_for_build_wheel(config_settings=None):
return []
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
name, ver = "buildme", "1.0.0"
dist = f"{name}-{ver}.dist-info"
files = {"buildme.py": "VALUE = 42\\n",
f"{dist}/METADATA": f"Metadata-Version: 2.1\\nName: {name}\\nVersion: {ver}\\n",
f"{dist}/WHEEL": "Wheel-Version: 1.0\\nGenerator: tinybackend\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n"}
rec = []
for p, b in files.items():
d = base64.urlsafe_b64encode(hashlib.sha256(b.encode()).digest()).rstrip(b"=").decode()
rec.append(f"{p},sha256={d},{len(b.encode())}")
files[f"{dist}/RECORD"] = "\\n".join(rec + [f"{dist}/RECORD,,"]) + "\\n"
fn = f"{name}-{ver}-py3-none-any.whl"
with zipfile.ZipFile(os.path.join(wheel_directory, fn), "w") as z:
for p, b in files.items():
z.writestr(p, b)
return fn
'''
wheel("tinybackend", "1.0", {"tinybackend.py": backend})
sdist = out / "buildme-1.0.0.tar.gz"
with tarfile.open(sdist, "w:gz") as t:
for p, body in {
"buildme-1.0.0/pyproject.toml": '[build-system]\nrequires = ["tinybackend"]\nbuild-backend = "tinybackend"\n',
"buildme-1.0.0/PKG-INFO": "Metadata-Version: 2.1\nName: buildme\nVersion: 1.0.0\n",
}.items():
data = body.encode()
info = tarfile.TarInfo(p); info.size = len(data); info.mtime = 1767225600
t.addfile(info, io.BytesIO(data))
for f in sorted(out.iterdir()):
print(f.name, hashlib.sha256(f.read_bytes()).hexdigest())
The scenarios install into throwaway virtual environments with --no-index, so pip can only see the local folder. Scenario 2 rewrites the colorlib wheel under the same file name, the way a tampered mirror would:
#!/bin/sh
# Every install is offline: --no-index plus a local folder of dists built by make_dists.py.
PIP="python3 -m pip --disable-pip-version-check"
h() { $PIP hash "dists/$1" | sed -n 's/^--hash=//p'; }
GREET=$(h greet-1.0.0-py3-none-any.whl) COLOR=$(h colorlib-2.0.0-py3-none-any.whl) BUILD=$(h buildme-1.0.0.tar.gz)
run() { # run NAME REQFILE [extra pip args]
name=$1 req=$2; shift 2
rm -rf env && python3 -m venv --without-pip env
printf '\n== %s\n' "$name"
$PIP --python env/bin/python install -q --no-index --find-links dists -r "$req" "$@" 2>&1 \
| grep -E '^ERROR|^ [a-z]|Expected|Got|^Successfully|Installing' | sed 's/^/ /'
[ "${PIPESTATUS:-0}" ] ; ls env/lib/python3*/site-packages | grep -E '^(greet|colorlib|buildme)[^-]*\.py$' | tr '\n' ' ' | sed 's/^/ installed: /'; echo
}
printf 'greet==1.0.0 --hash=%s\ncolorlib==2.0.0 --hash=%s\n' "$GREET" "$COLOR" > full.txt
run "1. every requirement pinned and hashed" full.txt
cp dists/colorlib-2.0.0-py3-none-any.whl colorlib.orig
python3 - <<'PY'
import zipfile
src = zipfile.ZipFile("colorlib.orig")
with zipfile.ZipFile("dists/colorlib-2.0.0-py3-none-any.whl", "w") as z:
for i in src.infolist():
body = src.read(i)
if i.filename == "colorlib.py":
body += b"# tampered: imagine a payload here\n"
z.writestr(i, body)
PY
run "2. same file name, different contents" full.txt
mv colorlib.orig dists/colorlib-2.0.0-py3-none-any.whl
printf 'greet==1.0.0 --hash=%s\n' "$GREET" > partial.txt
run "3. dependency left out of the file" partial.txt
printf 'greet>=1.0 --hash=%s\ncolorlib==2.0.0 --hash=%s\n' "$GREET" "$COLOR" > unpinned.txt
run "4. requirement not pinned with ==" unpinned.txt
run "5. pip 26.2 --no-require-hashes on the partial file" partial.txt --no-require-hashes
printf 'buildme==1.0.0 --hash=%s\n' "$BUILD" > sdist.txt
run "6. sdist whose build backend is not in the file" sdist.txt
run "7. same, with --only-binary :all:" sdist.txt --only-binary :all:
rm -rf env
#!/bin/sh
PIP="python3 -m pip --disable-pip-version-check"
h() { $PIP hash "dists/$1" | sed -n 's/^--hash=//p'; }
TB=$(h tinybackend-1.0-py3-none-any.whl) BUILD=$(h buildme-1.0.0.tar.gz)
show() { grep -E '^ERROR|Expected|Got' | sed 's/^/ /'; ls env/lib/python3*/site-packages | grep -E '^(buildme|tinybackend)\.py$' | tr '\n' ' ' | sed 's/^/ installed: /'; echo; }
fresh() { rm -rf env && python3 -m venv --without-pip env; printf '\n== %s\n' "$1"; }
printf 'tinybackend==1.0 --hash=sha256:%064d\n' 0 > build-constraints.txt
fresh "8. --build-constraint with a wrong hash for the backend"
$PIP --python env/bin/python install -q --no-index --find-links dists -r sdist.txt --build-constraint build-constraints.txt 2>&1 | show
printf 'tinybackend==1.0 --hash=%s\n' "$TB" > build-reqs.txt
fresh "9. hashed build requirements first, then --no-build-isolation"
$PIP --python env/bin/python install -q --no-index --find-links dists -r build-reqs.txt 2>&1 | show
$PIP --python env/bin/python install -q --no-index --find-links dists -r sdist.txt --no-build-isolation 2>&1 | show
rm -rf env
With pip 26.2.1 on Python 3.14.7:
$ ./scenarios.sh
== 1. every requirement pinned and hashed
installed: colorlib.py greet.py
== 2. same file name, different contents
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
colorlib==2.0.0 from file:///.../lab/dists/colorlib-2.0.0-py3-none-any.whl (from -r full.txt (line 2)):
Expected sha256 65af2785cea23b24c988d29876898a18236c6a82405890df12d184e4b5e9eeaf
Got 863d323ccc5134e46c727a9ba0d7f1d864d87730bfe30751105255f92fee6cdd
== 3. dependency left out of the file
ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements. Here is a list of those requirements along with the hashes their downloaded archives actually had. Add lines like these to your requirements files to prevent tampering. (If you did not enable --require-hashes manually, note that it turns on automatically when any package has a hash.)
colorlib==2.0.0 --hash=sha256:65af2785cea23b24c988d29876898a18236c6a82405890df12d184e4b5e9eeaf
== 4. requirement not pinned with ==
installed: colorlib.py greet.py
== 5. pip 26.2 --no-require-hashes on the partial file
installed: colorlib.py greet.py
== 6. sdist whose build backend is not in the file
installed: buildme.py
== 7. same, with --only-binary :all:
ERROR: Could not find a version that satisfies the requirement buildme==1.0.0 (from versions: none)
ERROR: No matching distribution found for buildme==1.0.0
$ ./scenarios2.sh
== 8. --build-constraint with a wrong hash for the backend
installed: buildme.py
== 9. hashed build requirements first, then --no-build-isolation
installed: tinybackend.py
installed: buildme.py tinybackend.py
Scenarios 1 to 3 are the mode working as designed: the tampered wheel is rejected with both hashes shown, and the missing dependency is caught with the exact line to add. Scenario 5 shows --no-require-hashes installing colorlib without any check. Scenarios 6 and 8 are the build gap: the backend installed with no hash, and the deliberately wrong hash in --build-constraint was ignored. Scenarios 7 and 9 are the two fixes.
Audit your files and pipelines
This script reads requirements files, following -r and -c includes, and flags partial hashing, --no-require-hashes, unpinned lines, editable, VCS and path requirements that can't be hashed, and extra indexes. With --scan it finds pip install commands in Dockerfiles, CI workflows and shell scripts, and says whether each one enforces hashes and blocks source builds. It reads files only:
#!/usr/bin/env python3
"""Audit pip hash-checking: requirements files and the pip commands that use them.
pip_hash_audit.py REQUIREMENTS.txt [...] check pins, hashes and risky options
pip_hash_audit.py --scan DIR find pip install commands in Dockerfiles, CI and scripts
Standard library only. Reads files; never runs pip.
"""
import pathlib
import re
import sys
WEAK = {"md5", "sha1", "sha224"}
NAME = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)(\[[^\]]*\])?\s*(.*)$")
def logical_lines(path):
"""Join backslash continuations, drop comments, keep line numbers."""
buf, start = "", None
for n, raw in enumerate(path.read_text(errors="replace").splitlines(), 1):
line = re.sub(r"(^|\s)#.*$", "", raw).rstrip()
if start is None:
start = n
if line.endswith("\\"):
buf += line[:-1] + " "
continue
buf += line
if buf.strip():
yield start, buf.strip()
buf, start = "", None
def audit_file(path, seen=None):
seen = seen if seen is not None else set()
path = pathlib.Path(path)
if path.resolve() in seen:
return [], 0, 0
seen.add(path.resolve())
findings, hashed, total = [], 0, 0
for n, line in logical_lines(path):
where = f"{path}:{n}"
if line.startswith(("-r ", "--requirement ", "-c ", "--constraint ")):
inc = path.parent / line.split(None, 1)[1]
sub, h, t = audit_file(inc, seen) if inc.exists() else ([(where, "WARN", f"included file not found: {inc}")], 0, 0)
findings += sub; hashed += h; total += t
continue
if line.startswith("-"):
opt = line.split()[0]
if opt == "--no-require-hashes":
findings.append((where, "HIGH", "--no-require-hashes: unhashed requirements will install unchecked"))
elif opt in ("--extra-index-url", "--trusted-host"):
findings.append((where, "WARN", f"{opt}: a second index or unverified TLS; hashes still apply, review anyway"))
elif opt in ("-i", "--index-url") and line.split()[-1].startswith("http://"):
findings.append((where, "WARN", "plain HTTP index"))
elif opt in ("-e", "--editable"):
total += 1
findings.append((where, "HIGH", "editable requirement cannot be hash-checked"))
continue
total += 1
algos = re.findall(r"--hash[= ]([a-z0-9]+):", line)
spec = re.sub(r"\s--hash[= ]\S+", "", line).strip()
if re.match(r"^(git\+|hg\+|svn\+|bzr\+)", spec) or "@ git+" in spec:
findings.append((where, "HIGH", f"VCS requirement cannot be hash-checked: {spec[:60]}"))
continue
if re.match(r"^(\.|/|file:)", spec):
findings.append((where, "HIGH", f"local path cannot be hash-checked: {spec[:60]}"))
continue
if algos:
hashed += 1
weak = sorted(set(algos) & WEAK)
if weak:
findings.append((where, "HIGH", f"weak hash algorithm {', '.join(weak)} (pip rejects these)"))
m = NAME.match(spec)
if m and not re.search(r"(^|[^=!<>~])==\s*[^\s,;*]+(\s|;|$)", m.group(3)) and " @ " not in spec:
findings.append((where, "WARN", f"not pinned with ==: {spec[:60]}"))
return findings, hashed, total
def report_requirements(paths):
worst = 0
for p in paths:
findings, hashed, total = audit_file(p)
if hashed and hashed < total:
opted_out = any("--no-require-hashes" in msg for _, _, msg in findings)
consequence = "they install unchecked" if opted_out else "pip refuses to install this file"
findings.append((p, "HIGH", f"{total - hashed} of {total} requirements have no hash; {consequence}"))
elif not hashed:
findings.append((p, "INFO", "no hashes: hash-checking mode is off for this file"))
print(f"{p}: {total} requirements, {hashed} hashed")
for where, level, msg in findings:
print(f" {level:5} {where}: {msg}")
worst = max(worst, {"INFO": 0, "WARN": 1, "HIGH": 2}[level])
return 1 if worst == 2 else 0
PIP_CMD = re.compile(r"\b(?:python[0-9.]*\s+-m\s+)?pip[0-9.]*\s+install\b[^\n&|;]*")
def scan(root):
pats = ("Dockerfile*", "*.Dockerfile", "*.yml", "*.yaml", "*.sh", "Makefile", "tox.ini", "noxfile.py")
files = sorted({f for pat in pats for f in pathlib.Path(root).rglob(pat) if f.is_file()})
for f in files:
for n, line in enumerate(f.read_text(errors="replace").splitlines(), 1):
for m in PIP_CMD.finditer(line):
cmd = m.group(0).strip()
notes = []
if not re.search(r"\s(-r|--requirement)\s", cmd + " "):
notes.append("no requirements file: nothing to hash-check")
elif "--require-hashes" not in cmd:
notes.append("no --require-hashes: only enforced if the file itself has hashes")
if "--no-require-hashes" in cmd:
notes.append("--no-require-hashes")
if "--only-binary" not in cmd and "--no-build-isolation" not in cmd:
notes.append("sdists allowed: build dependencies install without hash checks")
status = "ok " if not notes else "NOTE"
print(f"{status} {f}:{n}: {cmd[:90]}")
for note in notes:
print(f" - {note}")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--scan":
scan(sys.argv[2])
elif len(sys.argv) > 1 and not sys.argv[1].startswith("-"):
sys.exit(report_requirements(sys.argv[1:]))
else:
sys.exit(__doc__)
On three requirements files and a small repository:
$ python3 pip_hash_audit.py fx/requirements.lock fx/requirements-partial.txt fx/requirements-mixed.txt
fx/requirements.lock: 2 requirements, 2 hashed
fx/requirements-partial.txt: 1 requirements, 1 hashed
fx/requirements-mixed.txt: 4 requirements, 1 hashed
WARN fx/requirements-mixed.txt:1: --extra-index-url: a second index or unverified TLS; hashes still apply, review anyway
HIGH fx/requirements-mixed.txt:2: --no-require-hashes: unhashed requirements will install unchecked
WARN fx/requirements-mixed.txt:5: not pinned with ==: colorlib>=2.0
HIGH fx/requirements-mixed.txt:6: editable requirement cannot be hash-checked
HIGH fx/requirements-mixed.txt:7: VCS requirement cannot be hash-checked: tools @ git+https://git.example/team/tools@main
HIGH fx/requirements-mixed.txt: 3 of 4 requirements have no hash; they install unchecked
(exit 1)
$ python3 pip_hash_audit.py --scan fx/repo
NOTE fx/repo/.github/workflows/test.yml:4: python -m pip install -r requirements.lock
- no --require-hashes: only enforced if the file itself has hashes
- sdists allowed: build dependencies install without hash checks
NOTE fx/repo/.github/workflows/test.yml:5: python -m pip install --require-hashes -r requirements.lock
- sdists allowed: build dependencies install without hash checks
ok fx/repo/Dockerfile:3: pip install --no-cache-dir --require-hashes --only-binary :all: -r requirements.lock
NOTE fx/repo/Dockerfile:4: pip install gunicorn
- no requirements file: nothing to hash-check
- sdists allowed: build dependencies install without hash checks
A file can't show a missing transitive dependency, since that depends on what the packages declare. pip catches that one at install time, as scenario 3 shows. The script's job is the things pip lets through: opt-outs, unhashable lines, and commands that never asked for hashes.
What to do
- Generate hashed lock files with pip-tools or uv for every deployable environment, and commit them.
- Install with
--require-hashesexplicitly in Dockerfiles and CI, so a file that lost its hashes fails instead of quietly installing. - Add
--only-binary :all:to deployment installs. Where you need an sdist, pre-install hashed build dependencies and use--no-build-isolation. - Treat
--no-require-hashesas a review item. It's fine for local paths in a monorepo, but every unhashed line should have a reason. - Keep side installs out of hashed images. A later
pip install gunicornin the same Dockerfile undoes the work. Put it in the lock file. - Pair hashes with a review step. Hashes freeze a decision, so make the decision deliberately. Our posts on pinning Python dependencies and dependency cooldown periods cover choosing versions and waiting out fresh releases.
The fair view
Hash-checking mode is one of the best-designed safety features in Python packaging. It's strict by default, it explains every failure with the line you need, and it has stayed compatible since 2016. The build-dependency gap is documented honestly in pip's own source, and fixing it would break builds that work today. --no-require-hashes answers a long-standing complaint.
But "we use hashes" is a weaker statement than it sounds. A pipeline can have hashed lock files and still build an sdist with an unchecked backend, opt out with a flag, or install one package outside the file. The protection only holds where every install path asks for it. Check the commands, not just the files.