← All news
dependency-hygiene5 min read

The hidden risk of unpinned dependency versions in package.json

By Seaworthy · July 30, 2026

A silver combination padlock resting on a dark surface

Photo by Anne Nygård on Unsplash

What that little caret is actually doing

Open any package.json you've generated in the last two years and you'll see version numbers with a caret in front: "express": "^4.19.2". That caret looks harmless. It is not a version pin. It is a promise to npm that says: install anything from 4.19.2 up to, but not including, 5.0.0. A tilde (~) is a slightly tighter promise, patch releases only. And if you've got "latest" or a bare * sitting in there, you've made no promise at all. You've told npm to install whatever exists at install time, full stop.

The problem is the phrase "at install time." Your package.json doesn't describe the code you're running today. It describes a range of code you might run on any given Tuesday, depending on what the maintainer shipped between your last install and your next one.

Reproducible until it isn't

Here's the failure mode. You build a project in January. Tests pass, it ships, everyone moves on. Six months later someone clones the repo, runs npm install because there's a bug to fix, and the build breaks. Or worse: it doesn't break, it just behaves differently. A transitive dependency bumped a minor version and quietly changed a default, or dropped support for something you relied on, and nobody wrote a changelog entry that anyone read.

That's the annoying version of this problem. The dangerous version is when the new code isn't just different, it's hostile.

This isn't hypothetical. It's a pattern that's played out repeatedly in the npm ecosystem: a popular package's maintainer account gets phished, or a co-maintainer with commit access goes rogue, and a "patch" release goes out that looks unremarkable in the diff, a version bump, maybe a changelog line about a minor fix, but ships obfuscated code that harvests environment variables, hunts for crypto wallets, or exfiltrates CI secrets. It happens to packages with millions of weekly downloads, the kind everyone has as a transitive dependency three levels deep without ever having heard of them. Nobody reviews a patch bump to a package they've never directly interacted with. Why would they? Semver says patch releases are safe by definition. That's exactly the trust the attack is built on.

If your package.json says ^1.2.3, your next clean install pulls that malicious 1.2.9 without asking you anything. No warning, no diff to review, no decision point. npm install just does what it was told: get the newest thing that satisfies the range.

Pinning is not paranoia, it's the default you already have

The fix isn't exotic. It's the boring thing you were probably taught to skip because a tutorial told you carets were "best practice" for staying current.

Two things, together:

  1. Pin dependency versions exactly, no range operator, in package.json.
  2. Commit the lockfile.
// risky: floating range, resolves to whatever's newest at install time
{
  "dependencies": {
    "left-pad": "^1.3.0",
    "some-cli-tool": "latest"
  }
}
// pinned: this exact version, every time, everywhere
{
  "dependencies": {
    "left-pad": "1.3.0",
    "some-cli-tool": "2.4.1"
  }
}

The lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock) is doing most of the real work here already, if you're committing it. It records the exact resolved version of every dependency, direct and transitive, down to the integrity hash. A clean npm ci (not npm install) reads the lockfile and installs precisely what's in it, ignoring the ranges in package.json entirely. If you already commit your lockfile and use npm ci in CI, you're more protected than you think. If you don't, an npm install on a fresh clone re-resolves every range against the registry right now, and "right now" is exactly the moment an attacker wants you standing in.

So the honest version of this advice is: commit the lockfile, use npm ci in CI and deploy pipelines, and treat exact pins in package.json as the belt to the lockfile's braces, especially for anything with build-time execution power, postinstall scripts, build tools, CLI dependencies pulled in with npx.

None of this means never update. It means updates should be a decision you make on purpose, running npm update or bumping a version by hand, reading the diff or at least the changelog, then committing the new lockfile, rather than something that happens silently to whoever runs npm install next.

The trade-off, honestly

Pinning costs you something: you don't get security patches for free anymore, so you need some process, even a lightweight one like Dependabot or Renovate on a schedule, to bump versions on purpose. That's a real cost. It's just a much smaller one than a stranger's patch release running arbitrary code in your CI pipeline because nobody ever looked at it.

Seaworthy's dependency hygiene checks flag floating version ranges like * and latest in package.json for exactly this reason: the gap between what you think you're running and what actually gets installed is where the damage happens.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode