The Lockfile Hash Checked Out. The Package Was Still Malware.

After Microsoft's ChainDrop write-up in August I did something I should have done years ago: I opened a package-lock.json and tried to break it. I changed four characters of one integrity value, cleared the npm cache and ran npm ci. npm fetched the tarball, warned twice that it "seems to be corrupted", and refused to install with EINTEGRITY. The check works exactly as advertised.

And it would have let every major npm compromise of 2026 straight through. The integrity hash proves you got the bytes you locked. It says nothing about whether those bytes are safe — and an attacker who publishes a new version gets a new, perfectly valid hash for their payload. Axios in March, TanStack in May and the ChainDrop worm in August all shipped that way.

What the integrity field actually verifies

The integrity value in a lockfile is a Subresource Integrity string: the algorithm name, a dash, and the base64-encoded SHA-512 digest of the package tarball. The npm documentation describes it as "a sha512 or sha1 Standard Subresource Integrity string for the artifact that was unpacked in this location." You can reproduce one by hand:

npm view is-number@7.0.0 dist.integrity
# sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==

npm pack is-number@7.0.0
printf 'sha512-%s\n' "$(openssl dgst -sha512 -binary is-number-7.0.0.tgz | base64 -w0)"
# sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==

SHA-512 produces 64 bytes, and the base64 encoding turns them into 88 characters with two = of padding — the same digest the Hash Generator shows as 128 hex characters for a text input. Tamper with the lockfile and npm catches it immediately:

npm warn tarball tarball data for is-number@https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz (sha512-AAAAkg6e8...) seems to be corrupted. Trying again.
npm error code EINTEGRITY
npm error sha512-AAAAkg6e8... integrity checksum failed when using sha512: wanted sha512-AAAAkg6e8... but got sha512-41Cifkg6e8.... (3730 bytes)

So the lockfile binds a name@version to one exact set of bytes. That defends against a tampered mirror, a corrupted cache, or someone swapping a tarball on a registry that somehow allowed it. What it cannot do follows directly from how collision-resistant hashes like SHA-512 work: a hash identifies content, it does not judge it.

Every 2026 compromise shipped a new version with a valid hash

None of this year's big npm attacks modified an existing tarball. Each one published a new version through a legitimate account or pipeline, so the registry computed a correct integrity string for the malicious code like it does for any release:

Incident Date How it was published What ran on install Provenance
axios 1.14.1, 0.30.4 31 March 2026 A socially engineered maintainer A new dependency, plain-crypto-js@4.2.1, fetching multi-stage payloads including a remote access trojan —
84 versions of 42 @tanstack/* packages 11 May 2026 An OIDC token lifted from the GitHub Actions runner's memory, after cache poisoning via pull_request_target An optionalDependencies entry whose prepare hook ran a ~2.3 MB credential stealer Valid and signed
ChainDrop, 400+ packages incl. keyv, flat-cache, cache-manager Reported 4 August 2026 Stolen npm tokens and GitHub Actions OIDC access; the worm bumped the patch version of every package a token could reach and republished A preinstall script launching setup.mjs None — no matching commit, tag or release

A lockfile protected the projects that never re-resolved those packages during the attack window. It did nothing for the ones that did — npm install some-package, npm update, a Renovate or Dependabot pull request, a CI job that regenerates the tree, a new developer running npm install without the lockfile committed. ChainDrop was built for exactly that moment: a patch bump is what a ^ range picks up automatically.

npm ci is the part of the lockfile that does the work

A lockfile only freezes your tree if the install command refuses to change it. npm ci requires a lockfile, exits with an error when package.json and the lockfile disagree instead of updating anything, "will never write to package.json or any of the package-locks", and deletes an existing node_modules before starting. npm install is allowed to rewrite the lockfile to satisfy package.json.

If your CI or your Docker build still runs npm install, that is the first line to change. It will not stop a malicious version you deliberately upgraded to, but it guarantees that production gets the tree somebody reviewed rather than whatever the registry served at build time.

min-release-age buys the ecosystem time to notice

Malicious versions are usually spotted within hours, so refusing to install anything younger than a few days removes most of the risk. The TanStack timeline makes the case on its own. The first malicious batch went out at 19:20:39 UTC, an external researcher opened a GitHub issue at 19:46, all 84 versions were deprecated by 21:03, and npm removed the tarballs between 22:13 and 23:55. Snyk's analysis concluded that "a seven-day cooldown would have fully protected against this specific attack", and CISA's axios advisory recommends the same setting.

Every major package manager now supports it, with the catch that each picked a different name and unit:

Package manager Setting Unit Since
npm min-release-age days 11.10.0, 11 February 2026
pnpm minimumReleaseAge minutes 10.16 — default 1440 since pnpm 11
Yarn Berry npmMinimalAgeGate minutes 4.10.0
Bun minimumReleaseAge seconds 1.3

It fails loudly, which is the right way to fail. Here is npm 11.19 asked for a package whose only matching version is younger than the window — the date is 3,000 days before the day I ran it:

$ npm install is-number --min-release-age=3000
npm error notarget No matching version found for is-number@^7.0.0 with a date before 6/27/2018, 1:08:41 PM.

The cost is the flip side of the benefit: an urgent security fix is also a new release, and a seven-day window delays it by seven days. pnpm has minimumReleaseAgeExclude for exactly that, accepting names, @scope/* patterns and specific versions. Whatever manager you use, decide how you will bypass the window for a critical patch before you need to — not at 2am during an incident.

npm 12 no longer runs dependency install scripts by default

All three attacks executed during installation, before any of your code imported anything: TanStack through a prepare hook, ChainDrop through preinstall, and axios through the postinstall script of plain-crypto-js. npm 12, released in July 2026, shuts that door by default: npm install "will no longer execute preinstall, install, or postinstall scripts from dependencies unless they are explicitly allowed in your project". The same goes for prepare scripts from git, file and link dependencies, and for implicit node-gyp builds.

Two quieter npm 12 defaults matter just as much here. Git dependencies, direct or transitive, no longer resolve without --allow-git, and remote tarball URLs need --allow-remote. TanStack's payload arrived through an optionalDependencies entry pointing at a GitHub commit — a git dependency — and GitHub's changelog admits that path could execute code "even with --ignore-scripts", because a git dependency's .npmrc could override the git executable.

The allowlist lives in package.json, so it gets reviewed like any other change. npm approve-scripts --allow-scripts-pending lists every package that wants to run a script, and approving one writes it into the allowScripts block:

{
  "allowScripts": {
    "esbuild": true,
    "sharp": true
  }
}

On npm 11 the blunt equivalent is ignore-scripts=true in .npmrc, which CISA's axios advisory recommends. It stops lifecycle scripts from every package.json, your own project's prepare and postinstall included, while commands you invoke on purpose, such as npm test, still run their script. npm 11.16 and later also warn about everything npm 12 will block, which is the cheapest way to find your list before upgrading. pnpm reached the same conclusion earlier: since pnpm 10 it no longer runs dependency lifecycle scripts unless you allowlist the package. Either way, the packages with native builds or downloaded binaries make a short list you maintain once, against an attack surface that includes every transitive dependency you have never read.

Provenance proves where a package was built, not what was built

npm audit signatures verifies registry signatures and provenance attestations, and TanStack is the case that shows its limit. Snyk's write-up is precise about it: "npm received 84 valid, signed, provenance-attested package publishes." The attacker had not forged anything. They ran inside the real release pipeline with its real identity, so Sigstore faithfully attested that the real workflow built the malware. In Snyk's words, "What SLSA does not guarantee is that the code being built was safe."

I still run it. ChainDrop's versions had no corresponding commit, tag or release, and a package that normally ships with provenance suddenly publishing without it is one of the few cheap signals you get. Treat a missing attestation as an alarm and a present one as a statement about the build path, nothing more.

The Four Lines I Now Put in Every Node Project

One goes in .npmrc, committed to the repository so every developer and every CI runner gets it:

min-release-age=7

The second is the npm version itself: npm 12 on every machine and CI image, with its allowScripts block committed — or ignore-scripts=true next to the line above wherever you are still on npm 11. The third goes in CI and the Dockerfile — npm ci, never npm install — and the fourth runs before deploys: npm audit signatures. The integrity hash stays exactly where it belongs, guaranteeing that the tree you ship is the tree you locked. Time and not executing install scripts are what keep the tree you locked from being the one an attacker published forty minutes ago.

More Articles

WebMCP Lets a Page Hand Agents Its Tools — If the Page Holds a Trial Token

WebMCP is in a Chrome origin trial until 16 November 2026. The document.modelContext API, declarative forms, what broke for early adopters, and the risks.

11 October, 2026

The Agent Skills That Pay Off Are Scripts With a Manual Attached

Curated skills add 16.6pp, agent-written ones add nothing, and half never trigger. What makes a SKILL.md pay off, and how to vet third-party skills.

3 October, 2026

Your CLAUDE.md Describes Your Folders. The Agent Already Knows Them.

ETH Zurich found repo overviews in context files don't help agents find code. What belongs in CLAUDE.md, memory, skills and hooks instead.

29 September, 2026