For a few years the answer to npm supply chain attacks was provenance: publish from CI with a signed statement of which repository, commit and workflow built the package, and, once the package refuses token publishing altogether, a stolen laptop token can no longer push a malicious version. That answer was right about stolen tokens. 2026 showed what it doesn’t cover.
Three attacks and a flaw
- TanStack, May. 84 malicious versions across 42
@tanstack/*packages were published in six minutes. No npm credential was stolen. According to TanStack’s postmortem, a pull request from a fork triggered apull_request_targetworkflow that ran the fork’s code and poisoned the GitHub Actions cache; the release workflow later restored that cache, and the attacker’s code read the OIDC token from the runner’s memory and published with it. The versions carried valid provenance, and each added an optional dependency on a git commit whosepreparescript ran the payload. - AsyncAPI, July. A
pull_request_targetworkflow exposed a bot account’s token; the attacker pushed commits as that bot, and the project’s own release workflows published five poisoned versions of four packages. Microsoft’s analysis notes that the attestations correctly named the repository, commit and workflow. The payload ran when the package was imported, not when it was installed. - ChainDrop, August. A self-propagating worm re-published the packages it reached, mostly with stolen npm tokens and, in some targeted workflows, through GitHub Actions OIDC, so some of its versions carried valid provenance. Its releases added a
preinstallhook (Microsoft). - SlugFest, October. Not an attack but a disclosed flaw: Bloom Security found that npm’s trusted publishing matched a repository by its name. When a repository had been renamed, transferred or deleted before its first trusted publish, anyone who claimed the old name could publish the package. They counted over 1,500 exposed packages (Help Net Security). According to Bloom, npm shipped a fix on 2 October: unvalidated configurations now expire after 48 hours, and a configuration is bound to the repository’s permanent id on its first successful publish. It isn’t clear whether configurations validated before the fix were re-bound.
The pattern: in two of the attacks, and in part of the third, the malicious version came out of the real pipeline, so it was signed and attested exactly like a good one. And both pipelines were opened the same way, by a pull_request_target workflow that ran code from outside contributors with the project’s secrets in reach.
What provenance proves
A provenance attestation answers “where was this built?”: this tarball came from this repository, at this commit, from this workflow, on a hosted runner. That’s valuable. Combined with a package setting that disallows token publishing, it kills the cheapest attack (publishing from a stolen token on someone’s laptop), and it tells responders exactly which commit to look at. A version without provenance, from a package that always had it, is itself a red flag: npm audit signatures checks the attestations of what you installed, and pnpm’s trustPolicy: no-downgrade refuses versions whose evidence is weaker than their predecessors’.
It does not answer “should this have been built?”. An unauthorised commit, a workflow tricked into running attacker code, or a token lifted from a runner’s memory all produce honest attestations of a dishonest release. Provenance is necessary and no longer sufficient. The rest has to come from layers that don’t trust the pipeline.
For everyone who installs packages
Everything below ran on npm 12.2.0.
1. A lockfile, installed with npm ci. A lockfile pins every package, direct and transitive, to an exact version and integrity hash, so a malicious version published tomorrow can’t reach a build that only runs npm ci. It refuses to “fix” a mismatch on its own:
$ npm ci
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
package-lock.json are in sync. Please update your lock file with `npm install`
before continuing.
The exposure is concentrated in the moments a lockfile changes: npm install <pkg>, npm update, and the bots that open update pull requests.
2. A release-age cooldown. Malicious versions are usually found and pulled within hours or days, because thousands of people and scanners look at every popular release. A cooldown makes your installs wait that out. Since npm 11.10, one line in .npmrc does it:
min-release-age=7
npm then resolves only versions published more than seven days ago. I checked it on the npm package itself: with a 30-day window, npm install npm picked 12.0.2, from late July, instead of 12.1.0, which was about four weeks old. pnpm (minimumReleaseAge), Yarn (npmMinimalAgeGate) and Bun (install.minimumReleaseAge) have the same idea, in different units, and recent pnpm versions turn it on by default. Dependabot and Renovate can apply a cooldown to their update pull requests too.
The trade-off is security fixes: npm audit fix won’t install a patch younger than the window, and says so. List the package in min-release-age-exclude when you need a fix today. In my experience that’s rare, and worth it.
3. Install scripts, blocked by default. preinstall, install and postinstall scripts (and prepare for git dependencies) used to run for every dependency, which made them the favourite delivery route. npm 12 blocks them unless you’ve allowed the package:
$ npm install esbuild
npm warn install-scripts 1 package had install scripts blocked because they are
not covered by allowScripts:
npm warn install-scripts esbuild@0.28.2 (postinstall: node install.js)
npm warn install-scripts Run `npm install-scripts ls` to review, or
`npm install-scripts approve <pkg>` to allow.
Approving writes the decision into package.json, pinned to the version you reviewed, so a new version of the same package has to be approved again:
"allowScripts": { "esbuild@0.28.2": true }
npm 12 also refuses dependencies that point at a git repository unless you allow them (allow-git defaults to none), which would have stopped TanStack’s optional git dependency from being fetched at all.
Remember AsyncAPI, though: its payload ran on import, and no install-script policy stops that. Blocking scripts removes one route, not the problem, which is why the cooldown matters more.
4. Keep secrets away from installs. A malicious package runs with whatever the process that loads it can reach. In CI, install and test in a job that has no deploy keys or publish tokens, and give each workflow the least permissions: it needs. On developer machines, the cooldown and the script policy are most of what you have, so commit them with the project (min-release-age in .npmrc, allowScripts in package.json) and make sure everyone runs a client that understands them: npm before 11.10 ignores the cooldown, and npm before 12 runs every install script. devEngines, or packageManager with Corepack, can enforce the version. npx and global installs don’t use the project’s settings, so put min-release-age in your user ~/.npmrc too.
Advertisement
For people who publish packages
Use trusted publishing, and turn tokens off. A long-lived token in a repository secret is still the easiest thing to steal, so revoke old automation tokens and set the package’s publishing access to require two-factor authentication and disallow tokens. Then scope trusted publishing as narrowly as it allows. With npm 11.15 or later, npm trust github --file release.yml --environment npm --allow-publish binds the package to one workflow file and one GitHub environment.
Make the environment a gate. A GitHub environment can require a reviewer before a job runs, and can be limited to selected branches and tags (a tag pattern, protected by a ruleset, if you release from tags). That turns “any code that reaches the workflow can publish” into “a person approved this release”, which would have caught AsyncAPI’s unauthorised commits. It wouldn’t have caught TanStack’s: the poisoned cache ran inside a release a maintainer had legitimately started.
Keep outside code away from the release job. That’s what would have stopped both. Don’t use pull_request_target to check out or run code from forks, don’t restore caches (actions/cache, or setup-node’s cache) in jobs that publish, and pin third-party actions to a commit SHA. Publish only from workflows started by a tag or by hand.
Or stage, then approve. npm stage publish uploads a version that isn’t public until a maintainer approves it with 2FA. CI keeps doing the work, and a human is present for the moment it goes live. For that to be a real gate, create the trust configuration with only --allow-stage-publish, not --allow-publish, and keep token publishing disallowed; otherwise CI or a stolen token can skip the approval.
Audit your trust configurations. npm trust list shows what can publish each package. After renaming or transferring a repository, check that every configuration still points at a repository you control, and recreate it if not. That’s the gap SlugFest used.
The same ideas in .NET
NuGet has the same moving parts, and some of the same defences. A lock file, restored in locked mode in CI, fails the build instead of resolving something new:
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
$ dotnet restore --locked-mode
error NU1004: The package reference Humanizer.Core version has changed from
[2.14.1, ) to [2.8.26, ). The packages lock file is inconsistent with the project
dependencies so restore can't be run in locked mode. …
And package source mapping in nuget.config says which feed each package may come from, so an internal package name can’t be resolved from nuget.org by someone who registers it there:
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="internal">
<package pattern="Contoso.*" />
</packageSource>
</packageSourceMapping>
Two gaps remain. The NuGet client has no release-age cooldown of its own, so the cooldown has to live in Dependabot or Renovate. And a package can run code during your build through the MSBuild .props and .targets files it ships, with no allowlist like npm’s: review new build-time dependencies as carefully as install scripts.
What I’d do this week
- Commit a lockfile and install with
npm ci(ordotnet restore --locked-mode) everywhere but your own machine. - Add
min-release-age=7to the project’s.npmrc, and a cooldown to your update bot. - Upgrade to npm 12 and review the install scripts it blocks, one package at a time.
- Keep publish credentials out of jobs that install dependencies.
- If you publish: disallow tokens, use trusted publishing bound to one workflow and one protected environment, or staged publishes with a human approval, and keep fork code and caches out of release jobs.
- Run
npm trust liston every package after a repository rename.
Provenance still belongs on that list. It just isn’t the whole list any more.


