Dependency pinning is the practice of locking every package your build consumes — direct and transitive — to an exact version, and ideally an exact content hash, so that two builds of the same commit always resolve identical dependencies. Without pinning, a version range like ^4.17.0 means "whatever the registry serves today," and the registry's answer can change between your CI run and your production deploy. Every major registry-side attack of the past few years — event-stream, ua-parser-js, node-ipc, the PyTorch nightly compromise — hurt unpinned consumers first and worst.
Pinning is not one setting. It's a lockfile, a hash-verification mode, an install command that respects both, and a process for updating them. Miss any one of those and you're only partially pinned.
Version ranges are a promise the registry can break
Manifest files mostly express intent, not resolution. "express": "^4.18.0" in package.json accepts anything from 4.18.0 up to (but not including) 5.0.0. requests>=2.28 in a requirements.txt accepts anything newer, forever. The moment a maintainer account gets phished and a malicious 4.18.99 lands on npm, every unpinned CI job on the planet starts pulling it within minutes.
That's the security case. The engineering case is reproducibility: when Tuesday's hotfix build resolves different transitive versions than Monday's release build, you're debugging a diff you can't see.
Lockfiles: what pinning looks like per ecosystem
| Ecosystem | Lockfile | Hash verification | Install command that respects it |
|---|---|---|---|
| npm | package-lock.json | integrity (SRI, sha512) | npm ci |
| pnpm | pnpm-lock.yaml | built-in | pnpm install --frozen-lockfile |
| Python (pip) | requirements.txt via pip-tools | --require-hashes | pip install -r requirements.txt --require-hashes |
| Python (Poetry) | poetry.lock | built-in | poetry install --sync |
| Go | go.sum | built-in, checked against sum.golang.org | go build (automatic) |
| Rust | Cargo.lock | built-in | cargo build --locked |
| Java (Gradle) | gradle.lockfile | via dependency verification XML | ./gradlew --write-locks, then normal builds |
Two traps worth calling out. First, npm install will happily rewrite your lockfile if it disagrees with package.json; only npm ci treats the lockfile as authoritative and fails on mismatch. Your CI should never run bare npm install. Second, a Python requirements.txt full of == pins without --hash=sha256:... lines still trusts the registry to serve the same bytes for the same version — which is exactly the assumption a registry compromise violates. pip-compile --generate-hashes fixes that. We've covered the Python-specific tradeoffs in more depth in pinning Python dependencies.
Pin the things that aren't packages too
Application dependencies are the obvious target, but the same logic applies anywhere your build pulls remote code:
- GitHub Actions.
uses: actions/checkout@v4is a mutable tag. The tj-actions ecosystem has shown how a retagged action becomes an instant supply chain compromise. Pin to the full commit SHA:uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332. - Docker base images.
FROM node:20-alpinefloats.FROM node:20-alpine@sha256:9e8f45fc...doesn't. Renovate can keep the digest fresh while your Dockerfile stays deterministic. - Terraform providers and modules.
.terraform.lock.hclexists for a reason; commit it, and runterraform providers lockfor every platform you build on. - curl-pipe-bash installers in CI. Ideally eliminate them; failing that, download to a file, check a sha256, then execute.
The staleness objection, answered
The standard pushback: "pinning means we'll rot on old, vulnerable versions." True, if you pin and walk away. The working pattern is pin-plus-automation:
- Everything pinned by lockfile and hash.
- Renovate or Dependabot opens update PRs on a schedule (daily for security advisories, weekly for the rest).
- CI runs the full test suite against the bumped lockfile.
- A human — or a merge policy for patch-level bumps with passing tests — merges.
This gets you deliberate, reviewed, tested upgrades instead of silent ones at install time. It also builds a useful audit artifact: every dependency change is a commit with a diff and a CI run attached. Some teams add a cooling-off period — Renovate's minimumReleaseAge: "3 days" — so a freshly published version can't reach your build until the ecosystem has had time to notice it's malicious. Most registry malware gets yanked within 48 hours; a three-day delay would have skipped the poisoned ua-parser-js versions entirely.
An SCA tool closes the loop by telling you which pinned versions actually need urgency. Pairing lockfiles with reachability-aware scanning means the update queue is ordered by real risk rather than by publication date — at Safeguard we treat the lockfile as the source of truth for the dependency graph precisely because manifests lie.
What pinning does not protect against
Honesty section. Pinning guarantees you get the bytes you resolved last time. It does not help when:
- The version you pinned was already malicious when you locked it (see: dependency review still matters).
- The attack is at build time on your own infrastructure rather than in a package.
- A dependency confusion attack exploits registry resolution order — pinning helps only if the pin includes registry provenance, which npm's
resolvedfield and Go's module proxy handle better than most.
Pinning is the floor, not the ceiling. It converts "the registry can change my build at any time" into "my build changes only when I commit a lockfile diff," which is the property everything else builds on.
Frequently asked questions
Is committing the lockfile enough to be "pinned"?
Only if your install command enforces it. Commit the lockfile and use the frozen-mode install (npm ci, --frozen-lockfile, --locked, --require-hashes) in CI. A committed lockfile that CI silently regenerates provides the feeling of safety without the substance.
Should libraries pin their dependencies too?
Libraries should commit lockfiles for their own CI (reproducible tests) but publish ranges in their manifest, because consumers need version flexibility to deduplicate trees. Pinning exact versions in a published library's manifest causes diamond-dependency conflicts downstream. Applications pin hard; libraries pin their CI and stay flexible in metadata.
How often should pinned dependencies be updated?
Security patches: as fast as your pipeline allows, ideally same-day via automated PRs. Everything else: weekly or biweekly batches work well, with a short cooling-off period (2–7 days after publication) to avoid ingesting malware before it's detected and yanked.
Does pinning to a version protect me if the registry is compromised?
Version pinning alone doesn't — a compromised registry can serve different bytes for the same version string. Hash pinning does: npm's integrity field, pip's --require-hashes, and Go's go.sum all verify content digests at install time, so substituted artifacts fail the install instead of reaching your build.