A version pin says which release to install. It does not say which file. If a mirror, a caching proxy or a private index ever serves a different click-8.5.0-py3-none-any.whl than the one you tested — through misconfiguration or compromise — a version pin installs it without complaint. A hash pin closes that gap: the installer computes the SHA-256 of every downloaded file and refuses anything that does not match the value recorded when you locked. This guide applies hash pinning to every place a Python CLI gets installed under your control — development, CI, release builds and container images — and covers the harder case of end users, who install from version ranges. It belongs to the supply-chain security topic.
Prerequisites
- A CLI project with a
uv.lock(or Poetry lock file); see locking and syncing CLI dependencies with uv. - uv 0.5+; Docker for the image section.
What a hash pin protects against
A lock file already records hashes. uv.lock stores the SHA-256 of every wheel and sdist for every locked version, so the question is not whether you have hashes but whether every installer checks them. There are three situations:
uv syncfromuv.lockverifies hashes automatically. Running it with--lockedadditionally fails if the lock file is out of date withpyproject.toml, so CI never silently re-resolves.- pip or
uv pipwith a requirements file verifies hashes only when the file contains--hashlines. With--require-hashes, it also refuses any requirement that lacks one — including transitive dependencies you forgot to list. - pipx,
uv tool install,pip install mytool— the way end users install — resolve dependency ranges fresh and check only what PyPI's metadata says. Your lock file plays no part.
Hash checking costs nothing measurable. The real cost is discipline: every dependency must be locked, including the ones pulled in transitively, and the lock must be updated deliberately.
The recipe
Development and CI: sync from the lock
uv sync --locked # install exactly what uv.lock says; fail if it is stale
uv run --locked pytest # same guarantee for one-off commands
--locked is the important flag in CI. Without it, a pyproject.toml change that was not followed by uv lock triggers a fresh resolution on the CI runner, and you test something no one reviewed. With it, the job fails with a clear message instead.
Anywhere pip is used: export with hashes
Some environments do not have uv — a minimal base image, a deployment system that only speaks pip. Export a fully hashed requirements file from the lock:
uv export --frozen --no-dev --no-emit-project --format requirements.txt -o requirements.txt
python -m pip install --require-hashes --no-deps -r requirements.txt
The exported file lists every package with all of its acceptable file hashes:
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via mytool
Each package has several hashes — one per wheel and one for the sdist — and the installer accepts any of them, so the same file works across platforms. --no-deps is belt and braces: the file is already complete, so pip should not resolve anything further. If the file is tampered with or a mirror serves a different artefact, the install stops:
Installing your own wheel by hash
The CLI itself should be installed with the same guarantee. Build the wheel once, record its hash, and install that exact file:
uv build --wheel
WHEEL=$(ls dist/mytool-*.whl)
echo "mytool @ file://$PWD/$WHEEL --hash=sha256:$(sha256sum "$WHEEL" | cut -d' ' -f1)" >> requirements.txt
python -m pip install --require-hashes --no-deps -r requirements.txt
This is exactly what smoke-testing the built wheel in CI needs: the artefact you test is provably the one you publish.
Container images
A Docker image of a CLI is a frozen environment, and the ideal place for full hash pinning. A two-stage build keeps uv out of the final image:
# Dockerfile
FROM python:3.13-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/uv
WORKDIR /src
COPY pyproject.toml uv.lock ./
RUN uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /requirements.txt
COPY . .
RUN uv build --wheel --out-dir /dist
FROM python:3.13-slim
COPY --from=build /requirements.txt /dist/ /tmp/install/
RUN python -m pip install --no-cache-dir --require-hashes --no-deps -r /tmp/install/requirements.txt \
&& python -m pip install --no-cache-dir --no-deps /tmp/install/*.whl \
&& rm -rf /tmp/install
USER 65534
ENTRYPOINT ["mytool"]
Every third-party package in the final image matches a hash from uv.lock; the only unhashed install is your own wheel, built moments earlier in the same build. Pin the base images by digest (python:3.13-slim@sha256:…) for the same reason you pin packages. Running as an unprivileged user limits what a compromised dependency could do at runtime.
End users: constraints and cooldowns
When someone runs pipx install mytool, the resolver chooses dependency versions from your declared ranges at that moment. You cannot force hashes on them through package metadata — and you should not pin exact versions in pyproject.toml, which would block security fixes and cause conflicts. Instead, offer reproducibility as an option and reduce the exposure window for everyone.
Publish a constraints file with each release — the hashed export from the lock, attached to the GitHub release. Users who need the exact tested set can install with it:
curl -LO https://github.com/acme/mytool/releases/download/v1.6.0/constraints.txt
uv tool install mytool==1.6.0 --constraints constraints.txt
Recommend a cooldown. uv's --exclude-newer accepts a duration such as "7 days" and ignores any file uploaded more recently; pipx offers --cooldown DAYS on install and upgrade. Malicious releases of popular packages are usually detected and removed within hours or days, so a week's delay protects users from most of them at the cost of receiving genuine fixes a few days later. Mention it in your installation docs next to the plain command.
Ship a locked artefact for users who need full control: a container image as above, or a standalone binary that bundles the exact locked dependencies.
UX considerations
- Make the CI failure obvious. A
--lockedfailure should say "runuv lockand commit the result"; add that hint to the workflow step name or a wrapper script. - Keep lock updates reviewable. Upgrade with
uv lock --upgrade-package NAMEfor targeted fixes, and let a bot propose broader upgrades on a schedule, so each lock diff is small enough to read. - Document the paranoid install. A short "verified install" section with the constraints and cooldown commands helps security teams adopt the tool.
- Do not hand-edit exported files. Regenerate from the lock; a hand-edited hash is either wrong or a mistake waiting to happen.
Testing the behaviour
The property worth testing is that installs fail when they should. A small CI job tampers with one hash and asserts the install is refused:
#!/usr/bin/env bash
# scripts/check-hash-enforcement.sh
set -euo pipefail
uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /tmp/req.txt
# Corrupt every hash of the first package in the file.
python - <<'PY'
import re
text = open("/tmp/req.txt").read()
start = re.search(r"^[A-Za-z0-9_.-]+==", text, re.M).start()
end = text.index("# via", start)
block = re.sub(r"sha256:[0-9a-f]{8}", "sha256:00000000", text[start:end])
open("/tmp/req.txt", "w").write(text[:start] + block + text[end:])
PY
uv venv -q /tmp/hash-check
if VIRTUAL_ENV=/tmp/hash-check uv pip install --no-cache --require-hashes -r /tmp/req.txt; then
echo "ERROR: install succeeded with corrupted hashes" >&2
exit 1
fi
echo "hash enforcement OK"
Corrupting every hash of a package matters: each package lists several acceptable hashes (one per file), and changing only one leaves the others valid. A second check — uv sync --locked after editing pyproject.toml without re-locking — should also fail; run it once by hand when setting up CI to see the message your contributors will get.
Conclusion
Hashes turn "the right version" into "the right file". Use uv sync --locked in development and CI, export a hashed requirements file for pip-based installs and container images, install your own wheel by hash, and test that tampering is refused. For end users, publish a constraints file, recommend a cooldown and offer locked artefacts — reproducibility as an option, without taking security fixes away from everyone else.
Frequently asked questions
Do hashes slow installs down?
Not noticeably. Installers hash files as they download them anyway for caching; comparing against a recorded value is free. The cost is only in maintaining the lock file.
Why does each package have several hashes?
A release can contain many files — wheels for different platforms and Python versions, plus an sdist. The lock records all of them so the same file works on Linux, macOS and Windows; the installer accepts whichever file it downloads, as long as its hash is on the list.
Should I commit the exported requirements.txt?
Usually not — generate it from uv.lock when needed, so there is one source of truth. Commit it only if a downstream system requires a static file, and add a CI check that it matches a fresh export.
Does pip install --require-hashes work with editable installs?
No; editable installs and local directories cannot be hashed. Install your own project as a built wheel by hash, as shown above, or install it separately with --no-deps after the hashed dependencies.