Dependency Pinning in JavaScript — Why It Matters More Than You Think

calendar_today Apr 1, 2026
schedule 8 min read

A few days into a fresh Astro project I ran npm install on a new machine and immediately hit dependency conflicts. The project was days old. Nothing had changed. And yet something had.

That’s the JavaScript ecosystem in a nutshell — a dependency graph that can shift under your feet without you touching a line of code.

This is Part 1 of a series on dependency management across ecosystems. We start with JavaScript because it’s where the problem is most acute — and where the tooling to fix it is most mature.

Series overview:

  • Part 1 — JavaScript/Node: pinning, lockfiles, supply chain attacks (you are here)
  • Part 2 — Going deeper: monorepos, peer dependency hell, overrides
  • Part 3 — Rust/Cargo: how it differs, where it still hurts
  • Part 4 — Go modules: the proxy model, minimal version selection
  • Part 5 — Cross-ecosystem patterns: what good dependency hygiene looks like

What Version Ranges Actually Mean

Open any package.json and you’ll see something like this:

{
  "dependencies": {
    "astro": "^4.5.2",
    "@astrojs/mdx": "~3.1.0",
    "sharp": "0.33.1"
  }
}

Three different version specifiers, three different behaviors:

  • ^4.5.2 — allows any version >=4.5.2 and <5.0.0. Minor and patch updates install automatically
  • ~3.1.0 — allows any version >=3.1.0 and <3.2.0. Patch updates only
  • 0.33.1 — exact version. Nothing else installs

^ is npm’s default when you run npm install <pkg>. It’s also where most of the pain comes from.

The assumption behind ^ is that maintainers follow semantic versioning faithfully — that minor versions never break anything, that patch versions only fix bugs. In practice, that assumption breaks regularly.

4.5.24.6.04.7.15.0.0^ allows any of thesepinned hereblocked by ^you wrote thisthey installed this

The Problem with Loose Ranges

Your build breaks on a fresh clone

You commit your code, your colleague clones it, runs npm install, and gets a different version of a dependency than you have. A minor update shipped between your install and theirs. The code that works on your machine doesn’t work on theirs.

This is the most common manifestation and the most immediately frustrating.

You have no idea what’s actually running

With loose ranges, npm install on the same machine two weeks apart can produce different results. Your package.json says ^4.5.2 but you don’t actually know whether you’re running 4.5.2, 4.6.0, or 4.7.1 without checking node_modules. That’s not a great property for software you’re shipping.

Supply chain attacks hit auto-updaters hardest

This is the one people underestimate until it happens. A few real examples:

event-stream (2018) — a popular npm package with millions of weekly downloads. A new maintainer was granted ownership, injected malicious code targeting a specific Bitcoin wallet. Projects using ^ pulled it in automatically on the next install.

ua-parser-js (2021) — the maintainer’s npm account was compromised. Malware was published as a patch version update — 0.7.29, 0.8.0, 1.0.1. Projects with loose ranges got it on the next npm install.

axios (March 31, 2026) — this one happened as this post was being written.

A lead axios maintainer’s npm account was compromised. The attacker changed the registered email to an anonymous ProtonMail address and manually published two poisoned versions — 1.14.1 and 0.30.4 — directly via the npm CLI, bypassing the project’s GitHub Actions CI/CD pipeline entirely.

Both versions inject plain-crypto-js@4.2.1 as a hidden dependency. The package was staged 18 hours in advance, its sole purpose a postinstall script that drops a cross-platform remote access trojan targeting macOS, Windows, and Linux. After execution, the malware deletes itself and replaces its own package.json with a clean version to evade forensic detection. Three separate payloads, pre-built for three operating systems. Both release branches poisoned within 39 minutes of each other. This was not opportunistic.

Axios has over 100 million weekly downloads. Any project using ^1.14.0 or ^0.30.0 would have pulled in the compromised version automatically on the next npm install. Both malicious versions have since been unpublished — but unpublishing doesn’t undo the damage on machines that already ran the postinstall script.

If you installed either version, assume your system is compromised and rotate all credentials immediately.

Malicious plain-crypto-js staged18 hours before the attackMar 30 · 23:59 UTCaxios@1.14.1 publishedHijacked maintainer accountMar 31 · 00:21 UTCaxios@0.30.4 publishedBoth branches hit in 39 minutesMar 31 · 01:00 UTC^ users auto-pulled the RAT100M+ weekly downloads exposedNext npm install

In all these cases, projects using exact pinning were either unaffected or had a clear audit trail showing exactly what changed and when.


The Counterargument: Pinning Has a Cost

Before going all-in on exact pinning, the counterargument deserves an honest hearing.

log4shell (2021) — one of the most severe vulnerabilities in recent memory. Projects that had pinned old versions of Log4j and never updated got hit hardest. Pinning without a process for intentional updates just means you’re running vulnerable software indefinitely.

Security patches matter — maintainers ship patch versions specifically to fix vulnerabilities. If you pin and never update, you’re opting out of those fixes.

Manual upgrade discipline is hard to maintain — it sounds reasonable to say “we’ll upgrade deliberately and intentionally.” Most teams don’t. It becomes tech debt. Six months later you’re three major versions behind on everything.

The tension is real. Pinning without a process for updates trades one risk for another.


Where to Actually Land

The answer isn’t “pin everything and never update” or “use loose ranges and accept the chaos.” It’s a middle ground that most serious teams converge on:

1. Always commit your lockfile

package-lock.json is not a build artifact — it’s source code. It records the exact resolved versions of every package in your dependency tree, including transitive dependencies. Without it, npm install is non-deterministic.

If your .gitignore has package-lock.json in it, remove it now.

2. Use npm ci on fresh installs

npm ci

npm ci installs strictly from package-lock.json. It ignores package.json version ranges entirely and fails if package-lock.json is missing or out of sync. This is what you should run in CI, on new machine setups, and anywhere you want a reproducible install.

npm install tries to be helpful — it resolves, updates the lockfile, and surprises you. npm ci does exactly what you told it to do.

3. Pin exact versions in package.json

Set npm to write exact versions by default:

npm config set save-exact true

Then any npm install <pkg> writes exact versions to package.json. Combined with committing the lockfile, you have two layers of pinning — the declared version in package.json and the fully resolved tree in package-lock.json.

{
  "dependencies": {
    "astro": "4.5.2",
    "@astrojs/mdx": "3.1.0"
  }
}

No carets, no tildes. What you wrote is what installs.

4. Automate intentional upgrades

This is the piece that resolves the tension with log4shell. You still need to update — but deliberately, with visibility, not silently.

Renovate or Dependabot open pull requests when new versions are available. Each PR includes the changelog, the diff, and CI results. You review, you merge when you’re ready. You get the security patches without the silent auto-updates.

Renovate is more configurable — you can group updates, set schedules, automerge patch-only updates after CI passes if you want that level of automation. Dependabot is simpler to set up if you’re on GitHub.

The key property: you always know what changed and when. Your git history becomes an audit trail.

Exact pinsave-exact trueCommit lockfilepackage-lock.jsonnpm ci on installStrict, reproducibleReproducibility layerRenovate botDetects new versionsOpens PRChangelog + CI resultsYou review + mergeIntentional, auditableUpdate layernpm audit in CI — catches known CVEs

5. Run npm audit in CI

npm audit --audit-level=high

Fails your build on high-severity vulnerabilities in your dependency tree. Not a substitute for keeping dependencies updated, but a safety net that catches known CVEs before they reach production.


Putting It Together

Here’s the full setup in practice:

# Set exact versions as default
npm config set save-exact true

# Install a new dependency — writes exact version
npm install astro

# Fresh clone or CI — install strictly from lockfile
npm ci

# Check for vulnerabilities
npm audit

# See what's outdated
npm outdated

And in your CI pipeline:

- run: npm ci
- run: npm audit --audit-level=high
- run: npm test

The Honest Summary

Loose version ranges are a convenience that trades away reproducibility and increases supply chain risk. Exact pinning gives you reproducibility and a smaller attack surface, but only if you pair it with a process for intentional updates.

The ^ default in npm optimises for “things stay up to date automatically.” That’s a reasonable default for getting started. It’s not a reasonable default for software you care about.

Commit your lockfile. Use npm ci. Pin exact versions. Automate update PRs with Renovate or Dependabot. Run npm audit in CI. That’s it.


What’s Next

In Part 2 we go deeper into the JavaScript ecosystem — monorepos, peer dependency conflicts, and the overrides field for when the dependency graph fights back.

Follow along for the rest of the series.