Skip to content
Pixels for Breakfast

npm supply-chain attacks: which defences actually stop them

· Menno-Paul Schreuder

Malicious npm packages get in through three doors. Which measures close which door, which ones only look like they do, and what a hardened repository looks like in practice.

  • Security

In May 2026, more than 160 npm packages, TanStack among them, were published with malicious code. The attackers had poisoned the build cache of the maintainers' own release pipeline and used it to publish new versions, complete with valid provenance (Orca Security (opens in a new tab)). In August, a hijacked maintainer account pushed malicious versions of keyv, cacheable and related packages: over 1,300 versions with a combined two billion monthly downloads (Chainguard (opens in a new tab)).

For the organisations using those packages, both attacks came down to the same thing: a malicious version entered their dependency tree and ran code on a developer's machine or a build server. The question is which of the measures you can take in your own repository would have stopped that, and which only look like they would.

Three ways in

  1. Code that runs when you install. A preinstall or postinstall script executes the moment the package is installed, before anyone imports it. Both 2026 attacks above used this.
  2. Code that runs when you use the package. No install script, just malicious code that runs when your code or your build imports it. The typosquat viteplugiin (MAL-2026-10546 (opens in a new tab)) ran its payload as soon as a Vite config loaded it.
  3. A poisoned cache in your own pipeline. The TanStack route, translated to your own repository: if your CI restores dependencies from a cache that untrusted runs (for example pull requests from forks) can write to, tampered packages can enter your build without your lockfile changing.

Which measure stops what

Measure Install scripts Code you import Poisoned CI cache
Install scripts blocked, explicit allowlist Stops it No No
Minimum release age Stops it, if spotted in time Stops it, if spotted in time No
Frozen lockfile, updates only via review New versions only enter when you update Same No
Trust policy: no downgrade Only if published outside the usual pipeline Same No
Cache tied to the lockfile, not writable by untrusted runs No No Stops it
Audit and vulnerability alerts Afterwards Afterwards Afterwards

A few things stand out.

Blocking install scripts does the most. It would have stopped both big attacks of 2026 on the consumer side. It's also no longer a reason to switch package managers: npm 12 (July 2026) blocks install scripts by default too, and pnpm has done so since version 10.

A minimum release age only works if someone notices first. It keeps brand-new versions out for a set period. Compromised releases are usually spotted and pulled quickly, but there's no guaranteed time. Pick a period that fits your risk: a day is the default in pnpm, and for this site I use a week.

Provenance is not a safety signal on its own. Both attacks were published from the projects' own (compromised) pipelines, so they carried valid provenance. As Chainguard puts it: provenance proves who published a package, not that their environment was trustworthy. A trust policy that rejects downgrades still helps against a classic stolen token, which publishes outside the usual pipeline.

A frozen lockfile buys you time. Your build never picks up a new version on its own. New versions only enter when someone updates deliberately, which is exactly when the release age and a review do their work.

Your machine is not your repository

When I applied this to this site's own repository, the minimum release age turned out to be three days already. Not because of the repository, but because of a global setting on my own machine. That protected me, and nobody else: a colleague, a CI server or the production server only sees what's in the repository.

So the settings belong in the repository. With pnpm 12 that's pnpm-workspace.yaml:

# Install scripts: unreviewed scripts make the install fail instead of running.
ignoreScripts: false
strictDepBuilds: true
allowBuilds: {}

# Don't install versions younger than 7 days (in minutes).
minimumReleaseAge: 10080

# Fail when a package is published with less trust than before.
trustPolicy: no-downgrade

# Only direct dependencies may come from git or tarball URLs.
blockExoticSubdeps: true

With npm, the minimum release age lives in the project's .npmrc as min-release-age, in days (npm 11.10 and later), and npm 12 blocks install scripts by default. pnpm adds stricter defaults on top: the release age is on out of the box, an install fails on unreviewed build scripts instead of skipping them, and the trust policy is built in.

When a policy fires

pnpm 12 checks your existing lockfile against these policies too, not just new installs. The first clean install of this repository failed on four entries. Three were simply younger than seven days: harmless, and old enough a few days later. The fourth was a trust downgrade: laravel-vite-plugin 3.2.0 had been published by hand, without provenance, while the versions before it came from the project's own pipeline.

That is exactly the signal the policy is for, so I checked it instead of waving it through: the publisher was the maintainer himself, the npm release matched his GitHub release from three minutes earlier, the package has no install scripts and its contents barely differ from the previous version. Legitimate, so it got an exception for that one version, with the reason and date next to it. The next release has to meet the policy again.

That's the routine: investigate, make a version-specific exception with a reason, and remove exceptions once they're no longer needed.

What this doesn't solve

Malicious code that runs when you import it, and is old enough to pass the release age, still gets through. So do attacks you only learn about afterwards. Keep running an audit in CI, keep secrets away from the install step of your pipeline, and when an incident hits a package you use, rotate the credentials it could have reached.

Where to start

  1. Commit your lockfile and install with a frozen lockfile in CI.
  2. Block install scripts and allow only the ones you have reviewed.
  3. Set a minimum release age that fits your risk.
  4. Put all of it in the repository, not in someone's global config.
  5. Check how your CI caches dependencies, and who can write to that cache.

Want help with this for your own repositories? It's a short, fixed-price job, see Frontend expertise.