Pinning Dependencies by Exact Version and Hash

Two builds of the same Git commit can pull different code. If your requirements.txt says requests>=2.31 and a new version ships between the build that passed your tests and the build that reached production, you shipped something you never tested — and you have no record of what.

A caret (^1.2.0), a tilde (~1.2.0) or a bare >= is an instruction to the installer to pick whatever satisfies the range at install time. That is convenient during development and a liability at release. The fix is to pin deliberately: exact versions, plus a cryptographic hash of each artefact, so the installer refuses anything it cannot verify byte-for-byte. This is what makes a build reproducible, and it is also the cheapest supply-chain control you can add — a pinned hash cannot be silently swapped for a poisoned package of the same name and version.

Version pin versus hash pin: they are not the same control

Pinning the version (requests==2.32.3) fixes which release you install. It does not fix what that release contains. Package registries are mutable enough, and account-takeover and typosquat attacks common enough, that a version number is an identifier, not a guarantee. A hash pins the actual bytes: the installer downloads the artefact, computes its SHA256, and aborts if it does not match. Version-plus-hash is the pairing that gives you a build you can reproduce and defend.

Free · 4 minutes

Would you survive contact with a determined attacker — or an auditor?

Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.

The other reason to pin both is transitive dependencies. Your application might declare ten packages; those ten pull in eighty more. A range on any one of the eighty is a hole. Hash-pinning forces you to enumerate the entire resolved tree, which is precisely the point — you cannot verify what you have not written down.

Generating a hashed requirements file with pip-tools

You do not write hashes by hand. In the Python world, pip-compile from pip-tools resolves your top-level dependencies into a fully pinned, fully hashed lockfile. Keep your intent in a requirements.in — loose ranges are fine here, this is the file humans edit — and compile it into a locked requirements.txt.

# requirements.in — the file you edit, intent only
fastapi
uvicorn[standard]
requests

# Compile it into a fully pinned, hashed lockfile.
# --generate-hashes writes a SHA256 for every artefact, including
# every transitive dependency in the resolved tree.
pip-compile --generate-hashes --output-file=requirements.txt requirements.in

The resulting requirements.txt pins each package with == and appends one or more hashes per package. A package can have several valid artefacts — a source distribution and multiple wheels — so multiple hashes per entry is normal and correct:

# This file is autogenerated by pip-compile. Do not edit by hand.
# (hash values below are illustrative)
requests==2.32.3 
    --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 
    --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
certifi==2024.7.4 
    --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b 
    --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90
# ... and so on for the full resolved tree

Commit both files. The .in records what you meant; the .txt records exactly what you got. Reviewers diff the lockfile and see every version and hash that changed — the same discipline I argue for in a pre-commit baseline that actually runs.

Enforcing it with –require-hashes

Generating hashes achieves nothing if the installer ignores them. Install with hash-checking mode turned on:

python -m pip install --require-hashes -r requirements.txt

Under --require-hashes, pip enforces an all-or-nothing rule: every requirement, including every transitive dependency, must be pinned with == and carry at least one hash, or the install fails outright. A range anywhere, or a single missing hash, aborts the whole install. That strictness is the feature — it means a green build genuinely verified the entire tree. The recommended algorithm is SHA256; weaker digests such as MD5 and SHA1 are rejected.

One footgun worth knowing: installing your own project alongside a hashed file can trip the check, because your local package has no registry hash. Install application dependencies from the locked file, then install your own code separately with python -m pip install --no-deps . so pip does not go off and fetch unverified dependencies you thought you had covered.

The npm side: lockfile integrity and npm ci

npm bakes this in. Every entry in package-lock.json carries an integrity field — a Subresource Integrity string, normally sha512, of the exact tarball. The catch is npm install, which will happily update the lockfile if package.json ranges permit. On a build server that is the wrong verb. Use npm ci instead:

# In CI, never 'npm install'. Use 'npm ci':
#  - installs strictly from package-lock.json
#  - verifies each package against its integrity hash
#  - fails (EINTEGRITY) on any hash mismatch
#  - errors if package.json and the lockfile disagree
npm ci

A hash mismatch surfaces as npm ERR! code EINTEGRITY and stops the build. Treat that error as a security event, not a flaky pipeline to retry — it means the bytes on the registry no longer match the bytes you locked. Commit package-lock.json, and never add it to .gitignore.

Upgrading on purpose, and where ranges still earn their place

Full pinning has a real cost: every upgrade is now an explicit, reviewed change rather than something that drifts in on its own. That is the trade you are buying. To upgrade, edit requirements.in (or bump a version in package.json), regenerate the lock, and review the diff:

# Re-resolve everything to the latest allowed, with fresh hashes.
pip-compile --upgrade --generate-hashes --output-file=requirements.txt requirements.in

# Or bump a single package deliberately:
pip-compile --upgrade-package requests --generate-hashes --output-file=requirements.txt requirements.in

# npm equivalent, then commit the changed lockfile:
npm update requests

The hashes are a natural home for automation. A tool such as Dependabot or Renovate opens the pull request, regenerates the lockfile, and lets a human approve the diff — the upgrade is deliberate, and the change history is auditable rather than mysterious.

Where do ranges remain acceptable? In libraries you publish for other people to consume, a range is correct — you want your library to co-exist with a consumer’s own pins, and pinning hard would create conflicts. The rule is clean: libraries express compatible ranges; applications and deployable services pin exactly and hash. The moment code is something you deploy rather than something others install, ranges stop being flexibility and become an unrecorded variable in your build.

This is the same philosophy behind hashing credentials rather than trusting a name: verify the bytes, not the label. Pin the version so you know which release; pin the hash so nobody can change what that release means underneath you. Then a rollback to last week’s commit gives you last week’s software, exactly — which is the whole point of a build you can trust, and a prerequisite for the change-failure and recovery metrics a mature delivery function is measured on.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.