Enforce pip hashes with an offline wheelhouse before deployment

John Burns

A dependency install can appear repeatable right up to the moment a package file changes behind an unchanged version pin. idna==3.10 tells pip which release to select. It does not, by itself, state which exact wheel or source archive the installer is allowed to accept. A hash-checked requirements file closes that gap by making the expected file digest part of the deployment input.

This guide builds a small offline wheelhouse and tests both outcomes with pip 26.1.2 on Python 3.13.5: a wheel whose SHA-256 digest matches the requirements file installs into a disposable target, while the same wheel with an intentionally wrong digest is rejected. The useful boundary is not a successful download. It is proving that the installer refuses a file when the declared digest and the bytes on disk disagree.

Make the artifact digest an install requirement

Pip’s --require-hashes mode requires every resolved requirement to have a supported hash. The requirement can include the digest directly:

idna==3.10 --hash=sha256:<expected-sha256>

The version pin and the hash solve different problems. The pin narrows the selected project release. The hash binds the installation to a particular artifact’s bytes. This is especially useful for a deployment job that installs from a reviewed wheelhouse, an internal package cache, or a lock file generated in a controlled build step.

Hash checking is strict by design. Once it is enabled, pip will not quietly fetch a dependency that lacks a hash just because the top-level requirement has one. That behavior exposes an incomplete dependency inventory before a deployment environment receives a partially verified set of packages.

Do not generate hashes from a file you did not first obtain through a trusted release process. The command below creates a digest, not trust. Record the release selection and provenance in the process that creates the wheelhouse, then review the resulting requirements file as an input to the release.

Build a disposable wheelhouse

Run this in a temporary directory, not in an application checkout. The example uses one small public package so the validation is easy to reproduce. A real build should download every direct and transitive artifact it needs, then store the reviewed wheelhouse according to its retention policy.

mkdir -p wheelhouse
python3 -m pip download --no-deps --only-binary=:all: \
  --dest wheelhouse 'idna==3.10'
sha256sum wheelhouse/idna-3.10-py3-none-any.whl

pip download resolves and retrieves the distribution without installing it. --no-deps keeps this focused test to one artifact; omit it only when the requirements file also contains hashes for every dependency. --only-binary=:all: avoids accidentally replacing the tested wheel with a source distribution that has a different digest and build path.

The validation run downloaded idna-3.10-py3-none-any.whl and produced this SHA-256 value:

946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3

Treat that value as evidence for this disposable run, not a value to copy into a production requirements file. Regenerate and review hashes for the artifacts and index policy your own build uses.

Create two deliberately different requirements files. The first contains the computed digest. The second uses sixty-four zeroes to exercise the rejection path.

printf 'idna==3.10 --hash=sha256:%s\n' '<computed-sha256>' \
  > requirements-good.txt
printf '%s\n' \
  'idna==3.10 --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000' \
  > requirements-bad.txt

Keeping the negative case next to the successful case matters. A pipeline that only demonstrates a matching install can still regress into a mode that ignores hashes. The failing input confirms that the check is active and positioned before the package becomes application code.

Install only from the reviewed files

Point pip at the local wheelhouse and disable indexes for the verification step:

python3 -m pip install --no-index --find-links wheelhouse \
  --require-hashes --target site-good \
  -r requirements-good.txt

--no-index prevents an unplanned network fallback. --find-links wheelhouse supplies the files pip is allowed to consider. --target site-good writes to a disposable directory rather than the interpreter’s normal environment; use a virtual environment or a build image in a real pipeline, but keep the same isolation principle.

A matching requirement should finish with a normal installation result. Verify the expected module exists before treating that output as a pass:

test -f site-good/idna/__init__.py
printf '%s\n' 'matching-hash install passed'

In the validation, pip reported Successfully installed idna-3.10, and the module was present in the disposable target. This proves the positive path did not merely download a file: pip accepted the declared digest and installed the selected wheel.

Now run the same command against the bad digest. Capture the status and stderr so a CI job can retain the reason for a failure:

set +e
python3 -m pip install --no-index --find-links wheelhouse \
  --require-hashes --target site-bad \
  -r requirements-bad.txt >bad.stdout 2>bad.stderr
status=$?
set -e
printf 'pip-exit=%s\n' "$status"

The pip 26.1.2 run exited with status 1. Its error began THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE, identified the expected all-zero digest, and printed the digest calculated from the wheel. No site-bad/idna/__init__.py file was created. That before-and-after check is the operational result to preserve: the same local artifact installed when its declared SHA-256 matched and was blocked when it did not.

Do not assert a full error sentence forever. Pip can improve diagnostics between releases. A durable automated check requires a nonzero exit status, confirms the message identifies a hash mismatch, and confirms the target does not contain the package. That combination detects both a missing enforcement flag and an unexpected partial installation.

Put the check in the build path

Hash checking works best when it is part of artifact preparation rather than a manual recovery step. A narrow delivery sequence is:

  1. Resolve and download dependencies in a controlled build environment.
  2. Review the exact versions, distribution files, and hashes that will be accepted.
  3. Store the requirements file with its hashes and the wheelhouse or an approved immutable package source.
  4. Install with --require-hashes and --no-index in the deployment build.
  5. Fail the build when any artifact is absent, changed, or unlisted.

If your application includes packages with platform-specific wheels, capture hashes for every artifact the supported platform can legitimately select. The requirements file may contain multiple --hash values for one pinned package when more than one reviewed distribution is acceptable. Do not use a broad wildcard or remove hash mode merely because a new platform artifact is missing; add the reviewed artifact and its digest deliberately.

A hash is one control in a larger release process. It does not replace an authenticated package index, package ownership review, vulnerability response, isolated build credentials, or runtime access controls. It does make the installer’s expected bytes explicit and testable. The offline wheelhouse test provides a compact release gate: a matching wheel installs, a mismatched wheel stops before it enters the target environment.

Sources