TanStack and the day provenance attestation stopped being a defense
On May 11, 2026, malicious npm artifacts were signed by TanStack's legitimate OIDC pipeline. Sigstore verified them. Every signature check we built for this case returned green. Here is what happened, what failed, and the one defense that still works.
TL;DR
On May 11, 2026, attackers published 84 malicious npm artifacts across 42 @tanstack packages. Within 48 hours, the same campaign reached 172 packages across npm and PyPI. The malicious versions were signed by TanStack’s legitimate OIDC publishing pipeline. Sigstore verified them. Provenance attestation showed them as authentic. They were indistinguishable from legitimate.
A 4-day release-age cooldown would have blocked every fresh install of those versions on a developer machine. It is the only defense in the standard supply-chain hardening kit that the attackers did not subvert.
This post is the first in a series where I walk through each major supply-chain incident from the last eight months: what was supposed to stop it, why those defenses failed, and what pkg-quarantine does about it. If you maintain a CI pipeline, or you use AI coding agents that install packages, the rest of this matters to you.
What happened
On Monday May 11, 2026, between 19:20 and 19:26 UTC, 84 malicious npm package artifacts were published across 42 packages in the @tanstack namespace (Aikido, Snyk, Socket).
TanStack is one of the larger framework ecosystems in JavaScript. @tanstack/react-router alone has around 12 million weekly downloads. @tanstack/react-query, @tanstack/react-table, and the rest of the family sit in the dependency tree of an enormous number of React applications. A successful publish in that namespace has the kind of blast radius that gets called by name.
The malicious versions executed at install time, harvesting credentials from ~/.npmrc, ~/.aws/credentials, ~/.ssh/, GitHub Actions environment variables, HashiCorp Vault tokens, and any cloud credentials they could find in the runner’s process tree. The harvested credentials were then used to attempt onward compromise of additional packages, which is why the campaign reached 172 packages across npm and PyPI within 48 hours rather than staying contained to TanStack (Wiz, Endor Labs).
Socket’s automated scanner flagged the artifacts within 6 minutes of publication. The TanStack team and npm pulled the versions later that evening. By then the campaign was already moving.
This is wave 4 of the Shai-Hulud campaign, the self-replicating worm that first appeared on npm in September 2025. The cadence so far:
- September 15, 2025: Shai-Hulud (original), hundreds of packages.
- November 2025: Shai-Hulud 2.0, 25,000+ malicious GitHub repos, Zapier / PostHog / Postman among the named hits.
- March 2026: Trivy npm packages, attributed to TeamPCP.
- April 22, 2026:
@bitwarden/cli, malicious for 93 minutes, also TeamPCP. - May 11–13, 2026: TanStack / Mini Shai-Hulud wave 4, 172 packages.
The attack chain
What makes this one different is not the payload. It is how the malicious versions were published.
TanStack does not let humans push directly to npm. Releases happen through a trusted-publishing pipeline: a GitHub Actions workflow with an OIDC identity that npm accepts as the legitimate publisher. The workflow signs each artifact with Sigstore and publishes the provenance attestation to npm. This is the recommended modern hardening posture. It is what every supply-chain hardening guide for the last two years has told maintainers to set up.
The attackers did not bypass it. They became it.
The chain, in order:
-
pull_request_targetPwn Request. TanStack had a workflow that ran onpull_request_targetand checked out the PR’s head ref before running.pull_request_targetruns with the privileges of the base repository, not the fork. Combined with checking out untrusted code from a fork, this lets a malicious PR execute arbitrary code with the workflow’s secrets and tokens. This is a known anti-pattern, well documented by GitHub themselves, but it is also a pattern that is easy to introduce by accident in any workflow that wants to interact with PRs from forks. -
GitHub Actions cache poisoning. Once the malicious PR’s code was running in the privileged context, the attackers used it to poison the Actions cache for the repository. Cached artifacts persist across workflow runs and are not scoped to PRs. Future runs of the release workflow would pull the poisoned cache.
-
OIDC token extraction from runner process memory. When the release workflow ran next, it requested an OIDC token from GitHub’s identity provider to authenticate as the trusted TanStack publisher to npm. The poisoned cache included tooling that read this token out of runner process memory at the exact moment it existed. The token has a short lifetime, but it does not need to last long. It needs to last one
npm publishcall. -
Publish through the legitimate pipeline. With the stolen OIDC token, the attackers called npm’s publish API as TanStack’s trusted publisher identity. npm accepted the request because the OIDC token was valid and freshly issued by GitHub. Sigstore signed the artifact because that is what the trusted-publishing flow does. The provenance attestation was attached because that is also what the flow does. The malicious version landed on npm bearing every signature and proof a defender could ask for.
In one sentence: the attackers did not forge TanStack’s identity. They borrowed it for as long as the OIDC token was alive, and used it through the actual TanStack publish path. Every check downstream of “is this a real TanStack release” returned yes, because it was a real TanStack release.
What was supposed to stop this
If you have been reading supply-chain hardening guides for the last two years, the list of defenses recommended to maintainers is roughly:
- 2FA on maintainer accounts, to stop account takeover by stolen passwords.
- Trusted publishing with OIDC, to remove long-lived npm tokens from CI. Tokens are issued per-run by GitHub, scoped to a specific workflow, short-lived.
- Sigstore provenance attestation: cryptographic proof that an artifact was built and signed by a specific workflow on a specific repository at a specific commit.
- npm provenance verification on install: consumers can require packages to have provenance, or use tools that reject packages without it.
npm auditon the consumer side, which pulls known vulnerability data from the npm advisory database.- GitHub Actions hardening: pinning third-party actions by commit SHA, restricting
GITHUB_TOKENpermissions, treatingpull_request_targetcarefully.
All of these are good controls. Most of them did exactly what they were supposed to do. None of them stopped this attack on a consumer.
Why those defenses failed
| Defense | What it does | What happened on May 11 |
|---|---|---|
| 2FA on maintainer accounts | Stops account takeover | Did not apply. No human credentials were stolen. |
| Trusted publishing with OIDC | Replaces long-lived tokens with short-lived ones | The short-lived token was stolen while it was alive. Its short lifetime did not help. |
| Sigstore provenance attestation | Proves an artifact was built by a specific workflow | The artifact was built by that specific workflow. Provenance was accurate. |
| npm provenance verification on install | Requires packages to have provenance | The malicious package had provenance. |
npm audit | Surfaces known vulnerabilities | At install time, the package was not yet in the advisory database. |
| GitHub Actions hardening | Reduces supply-chain risk in CI | The Pwn Request + cache poisoning chain bypassed most of the standard hardening. |
The single defense that did work, and worked exactly as designed, was detection time. Socket flagged the artifacts within 6 minutes. The TanStack team and npm pulled the versions later that day. “The package is gone after a few hours” only protects you if you were not in those few hours. If your CI ran a fresh install between 19:20 UTC on May 11 and the moment npm pulled the package, the runner pulled the malicious version. Everything downstream of that runner was compromised.
What a release-age cooldown does
A release-age cooldown is a setting that tells your package manager to refuse versions newer than N days. With min-release-age=4 in .npmrc, npm will not install @tanstack/react-router@<malicious-version> until that version has been live for at least four days. By then, every detection tool in the ecosystem has had time to flag it, the maintainer has had time to pull it, and the package is no longer on npm.
It is the supply-chain equivalent of waiting for the dust to settle. It does not stop attacks. It stops you from being the one who runs the install in the first five minutes.
This works because supply-chain attacks have a built-in time decay. Malicious versions are detected and pulled within hours to days. The exploit window is short. A 4-day cooldown sits comfortably outside that window for the bulk of attacks, including TanStack.
It does not require trusting any party. It does not assume anything about the publisher’s identity, the artifact’s signatures, or the dependency tree’s provenance. It just delays installs of fresh versions. That is enough.
The downside is real. Security patches are also fresh versions. If a CVE drops at 14:00 UTC and a fixed version ships at 14:15, a 4-day cooldown delays that fix by 4 days for everyone enforcing it. The mitigation is the same as the trade-off for any rate limiter: have an override (quarantine update --force in pkg-quarantine), document when to use it, and trust the maintainers of critical software to communicate when an override is warranted.
What pkg-quarantine does
pkg-quarantine is a small CLI that writes a release-age cooldown into every package manager on your machine in one command.
The native settings exist:
| Manager | Setting | Where |
|---|---|---|
| npm 11.10+ | min-release-age | ~/.npmrc |
| pnpm | minimum-release-age | ~/Library/Preferences/pnpm/rc (macOS) or ~/.config/pnpm/rc |
| bun | install.minimumReleaseAge | ~/.bunfig.toml |
| uv | exclude-newer | ~/.config/uv/uv.toml |
| yarn (per project) | npmMinimalAgeGate | .yarnrc.yml |
| deno (per project) | minimumDependencyAge | deno.json |
They live in six different config files with six different keys and six different units. quarantine init writes the right setting in the right place for every detected manager, without clobbering auth tokens or other settings.
quarantine audit then verifies they are actually in effect. This is not the same as “the file is on disk.” npm versions before 11.10.0 silently accept min-release-age while ignoring it. The setting is there, the audit passes from npm’s perspective, the package manager still installs fresh versions. quarantine audit catches this and flags it loudly. In CI, quarantine audit --exit-code fails the build.
For ecosystems without a native install-time gate (pip, gem, composer, cargo, hex), quarantine update checks the registry API before any global upgrade and refuses fresh versions. This is weaker than a native gate, because bare pip install foo still bypasses it. But it gates the common upgrade path.
Concretely, on a machine with quarantine init run and the default 4-day window, an attempt to install @tanstack/react-router at the time of the May 11 publish would fail. npm refuses the install with a message naming min-release-age as the reason. The malicious version never lands in node_modules. By the time the cooldown lifts, the version is no longer on npm.
What pkg-quarantine does not catch yet
Honest list:
- Already-installed malicious versions. If your
node_modules/had pulled a malicious@tanstack/react-routerbetween 19:20 UTC and the moment npm pulled it, the damage is done.pkg-quarantineis a prevention tool, not a remediation tool. - Bare
pip install foo(and friends) on a machine without a native gate.quarantine updateenforces the policy.pip installdirectly does not. A pip plugin to close this gap is on the roadmap. - Per-project configurations. Yarn and deno only have per-project release-age settings.
quarantine initfor those prints the snippet to add. We do not currently scan the filesystem to find every project and inject the setting. - Audit of
node_modules/after the fact. Aquarantine doctorcommand that scans installed packages against known incident IoC lists and warns when a known-bad version is present would close a real gap. This is the highest-priority item on the post-launch roadmap and the gap-closing feature that this article will be matched with. - Cross-registry IoC awareness. The pgserve worm hit npm AND PyPI from the same campaign. A cross-registry advisory feed integrated into
quarantine doctoris on the roadmap.
The point of this engineering blog is to walk through each major incident in the cadence and ship the gap-closing feature alongside the article. The May 11 wave shipped us quarantine doctor as the next thing. That work is in progress.
Try it
npm install -g @happyberg/pkg-quarantine
quarantine init
quarantine audit
If you are running CI:
- name: Verify quarantine policy
run: |
npm install -g @happyberg/pkg-quarantine
quarantine audit --exit-code
pkg-quarantine is MIT-licensed, has 154 tests, two runtime dependencies (commander + @iarna/toml), and zero transitive runtime deps. The code lives at github.com/happyberg/pkg-quarantine. Issues and feedback welcome.
The next post in this series covers the Bitwarden CLI incident from April 22, where the malicious version was live for 93 minutes. That one closes a different gap.
If you want to be notified when each incident post lands, the RSS feed for this engineering blog is the lowest-friction path. Otherwise the project follow-on on GitHub works too.
References
- Aikido: Mini Shai-Hulud Is Back: TanStack Compromised
- Wiz: Mini Shai-Hulud Strikes Again
- Snyk: TanStack npm Packages Hit by Mini Shai-Hulud
- Socket: TanStack npm Packages Compromised
- Endor Labs: Shai-Hulud Compromises the @tanstack Ecosystem
- CSO Online: Mistral AI SDK, TanStack Router Hit
- Unit42 (Shai-Hulud origin, Sept 2025): npm Supply Chain Attack
- Microsoft (Shai-Hulud 2.0): Shai-Hulud 2.0 Guidance
- Datadog Security Labs: The case for dependency cooldowns in a post-axios world