On May 11, 2026, between 19:20 and 19:26 UTC, a worm calling itself Mini Shai-Hulud published 84 malicious package versions across 42 different @tanstack/* packages on npm. The malware harvested cloud credentials, authentication tokens, and private keys from any machine that ran pnpm install or npm install while those versions were live.
My portfolio runs on TanStack Start, and it depended on every TanStack package family the attackers compromised. On paper, this was a perfect target.
I checked the lockfile. Zero of the 84 malicious versions resolved. The defenses I had configured held cleanly through one of the worst npm supply chain attacks of the year, and they're the same three I have been writing about and pushing into every project I touch.
What Mini Shai-Hulud actually did
The TanStack postmortem is worth reading in full, and Snyk's writeup has the cleanest forensic timeline I've seen. The short version is that the attackers never stole an npm token. They never phished a maintainer. They exploited a chain of three vulnerabilities in GitHub Actions.
A pull request opened against the public TanStack repo was allowed to trigger a workflow with elevated permissions. That workflow read its dependencies from the GitHub Actions cache, which the attacker had poisoned with a malicious pnpm store on an earlier run. When the workflow restored the poisoned cache, an attacker-controlled binary extracted the OIDC token from the runner's process memory. That token had the rights to publish to npm.
Six minutes later, 84 new versions were live. Each one carried a postinstall script that ran on the next machine to install the package, scanning the filesystem and process environment for credentials and exfiltrating anything it found.
The version numbers looked legitimate. They followed the existing release cadence. The deprecation notices and the npm registry pulldown didn't happen until the TanStack team got paged and reacted, which took longer than the publishing window itself.
This attack didn't need a compromised developer or a typosquat. The worm exploited the build infrastructure of the package itself, published faster than humans could review, and reached every installer the moment the registry served it.
Why my portfolio was the perfect target
I rebuilt my portfolio on TanStack Start in April 2026. I wrote about why I made that call and what the architecture looks like. At the time, the dependency tree pulled in basically every TanStack family the worm hit:
{
"dependencies": {
// direct
"@tanstack/react-router": "...",
"@tanstack/react-start": "...",
"@tanstack/react-router-devtools": "...",
"@tanstack/react-router-ssr-query": "...",
"@tanstack/router-plugin": "...",
"@tanstack/react-devtools": "..."
// and a long tail of transitive @tanstack/* packages
// pulled in by the ones above
}
}Worse, several of those direct dependencies had been pinned to "latest". I had been moving fast in the rebuild and never tightened them. On paper, the next pnpm install would have asked the registry "what is the newest version" for each of those entries, and during the attack window the answer would have been "the malicious one."
This is exactly the failure mode I wrote about in the npm supply chain post. Open-ended version ranges plus an automated install equals "the attacker decides what runs on your machine." It's not a hypothetical. It's what happens every time a worm like this lands.
And yet none of the bad versions resolved. Three defenses caught the attack, each at a different layer.
Defense 1: the lockfile
The single biggest reason nothing landed is the lockfile. pnpm-lock.yaml records, for every direct and transitive dependency, the exact version that was installed and a cryptographic integrity hash. Every @tanstack/* version recorded in mine had been published well before May 11.
When you run pnpm install with a lockfile present, pnpm doesn't go shopping at the registry. It reads the lockfile, asks for the exact versions listed there, verifies each tarball against the recorded hash, and refuses to install if anything doesn't match. The "latest" specifier in package.json is irrelevant at that point. The lockfile is the source of truth.
This is the same defense I wrote about for npm ci. The pnpm equivalent is pnpm install --frozen-lockfile, and you can make it the default by setting the flag in .npmrc:
# .npmrc
frozen-lockfile=trueWith that one line, every install in the project, on every developer machine, in CI, and in production, refuses to silently change versions. If package.json and the lockfile disagree, the install fails with a clear error. You go fix the lockfile intentionally, in a separate commit, with a review, and only then can the new version run.
The attacker's new versions weren't in my lockfile, so pnpm never fetched them.
Defense 2: minimum release age
The lockfile is the defense for installs that should reproduce. But what about installs where the lockfile itself has to change: a new dependency, a pnpm update, a deliberate bump to pick up a feature or a hotfix? What if I had run that update on May 11 at 19:23?
The answer is that pnpm would still have refused, because of a second defense I had configured: minimum-release-age.
This setting, added in pnpm 10.16 and which I wrote about as "advanced defense" in the locked-dependencies post, refuses to install any package version that was published less than N minutes ago. The number is up to you. A few common values:
# .npmrc
# Refuse to install versions younger than ...
minimum-release-age=1440 # 24 hours
minimum-release-age=4320 # 3 days
minimum-release-age=10080 # 7 daysThe Mini Shai-Hulud window was six minutes. From the moment the malicious versions were published to the moment TanStack and npm started deprecating them was several hours. Even a 24-hour cooldown would have made every malicious version uninstallable for the entire time they were reachable on the registry.
This is the defense that protects you against the "what if my lockfile is stale and I need to update" path. When you do update a dependency, the new resolution still has to survive the cooldown. A version that's two hours old is rejected by the package manager itself, with a clear error, before it ever touches your machine. The community gets time to catch the attack and pull the tarballs. You get to install the version that has been vetted, not the version that's fresh.
Here is what the rejection looks like:
$ pnpm add @tanstack/react-router@latest
ERR_PNPM_PKG_TOO_FRESH @tanstack/[email protected] was published less than 10080 minutes ago.
Published: 2026-05-11T19:23:12.000Z
Minimum release age: 10080 minutes (7 days)Pair it with the lockfile and you have defense in depth. The lockfile protects past installs. The cooldown protects future installs. Together they cover the entire surface where a fresh malicious version could reach your dependency tree.
If a genuine hotfix is published and you need it before the cooldown expires, you can add a single version to minimumReleaseAgeExclude in pnpm-workspace.yaml. You override one decision, with intent, in a commit that gets reviewed. You don't lower the global setting and move on.
Defense 3: the postinstall allow list
The third layer is the one that would have caught the attack if both of the first two had somehow failed.
The Mini Shai-Hulud payload was delivered via a postinstall script. That's true of almost every npm worm in recent memory. The malicious package itself is usually inert; what actually runs is the lifecycle script that npm executes immediately after extracting the tarball into node_modules. The script reads your environment variables, walks your filesystem looking for .npmrc and ~/.aws/credentials and ~/.ssh/id_*, and POSTs anything it finds to an attacker endpoint.
pnpm 10 ships a useful default for this: lifecycle scripts only run for packages in an explicit allow list. Everything else is silently skipped. The allow list lives in package.json:
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "lightningcss", "sharp"]
}
}Three packages. Each one has a legitimate native build step that has to run at install time. Every other dependency in the tree, including the entire @tanstack/* family, runs no install scripts at all.
Even if a malicious version had somehow slipped past the lockfile and the cooldown and made it onto disk, its postinstall payload would have been ignored. The package would have sat there as inert JavaScript files until something actually imported them. And for build-time dev tools that the SSR pipeline never bundles to the client, the practical impact of "inert files on disk" is close to nothing.
This is the same idea as the --ignore-scripts flag, except scoped per dependency. You opt in only the packages you trust to run native code, and the rest of the ecosystem can't run anything on your machine without you noticing.
Defense in depth
Each of these three defenses solves a different problem. They aren't interchangeable. The lockfile assumes you have already installed once and haven't updated. The cooldown protects you when you do update. The script allow list assumes both of the first two have failed and treats the malware itself as the last line.
Together they form a defense in depth:
Layer 1: pnpm-lock.yaml + frozen-lockfile=true
├─ Reinstalls reproduce the exact versions you had before.
├─ Drift triggers a hard fail, not a silent re-resolution.
└─ The "latest" specifier becomes a label, not a query.
Layer 2: minimum-release-age=10080
├─ Newly published versions are uninstallable for 7 days.
├─ Worm windows (minutes to hours) close before they can land.
└─ Community vetting happens before your machine sees the code.
Layer 3: onlyBuiltDependencies allow list
├─ Lifecycle scripts run for an explicit set of packages.
├─ A malicious postinstall in any other package is ignored.
└─ The script itself, the attack vehicle, never executes.To land, the worm needed me to install during the six-minute window with no lockfile, no cooldown, and lifecycle scripts enabled. None of those conditions held, and that's the point of layering: no single failure produces a compromise.
What I changed afterwards anyway
There was no actual exposure, and I still tightened everything I could.
The "latest" specifiers in package.json are gone, replaced by caret ranges pinned to the versions in the lockfile. The cooldown went from three days to seven, because the historical pattern is that compromised versions are caught and pulled within a day or two, and a longer window costs me very little. I added a Dependabot configuration with matching cooldown windows so update pull requests aren't even opened for versions younger than a week. I pinned the registry URL in .npmrc so that an injected user-level .npmrc can't redirect installs to a malicious mirror.
None of these were necessary, given the layers that already held. All of them tighten the margins for the next attack, which will arrive on a schedule I don't control.
The goal is a project that doesn't need you paying attention on the day of the attack.
What every project running npm should be doing today
If you maintain a Node project that runs pnpm install, npm install, or yarn install anywhere, including local dev, including CI, including production builds, here is the short list:
- Commit your lockfile. Whatever the package manager calls it.
pnpm-lock.yaml,package-lock.json,yarn.lock. Without a lockfile none of the other defenses matter. - Make frozen-install the default.
pnpm install --frozen-lockfile,npm ci,yarn install --immutable. Configure it in.npmrcso it's the default for every developer and every CI job. The npm supply chain post covers the full migration step by step. - Set a release-age cooldown if you're on pnpm. Pick a value between one day and one week and put it in
.npmrc. UseminimumReleaseAgeExcludefor the rare hotfix you can't wait on. If you aren't on pnpm, evaluate the migration. This single feature is one of the strongest arguments for it. - Allow-list your lifecycle scripts. Configure
onlyBuiltDependenciesinpackage.jsonto the smallest possible set. Anything you have to add to that list is something you have explicitly trusted to run code on your machine. The rest of the ecosystem can't. - Add Dependabot or Renovate cooldowns so update pull requests aren't opened for fresh versions. The cooldown windows should match (or be shorter than) your package manager's release-age setting.
None of these need new infrastructure or cost anything to run, and together they stop the whole Mini Shai-Hulud playbook.
Lock your dependencies
I dodged this one because the defenses I've been pushing on every project were already in place when the attack landed.
The next supply-chain worm is no longer hypothetical (see the postscript below). It will pick a different package family, exploit a different CI vulnerability, and publish in a different window. The defenses don't care which one it is. A locked dependency tree, a release-age cooldown, and a tight allow list on lifecycle scripts work against the entire class.
Set them up before the next attack, not during it.
Postscript: the worm is now open source
The day after the TanStack attack, TeamPCP, the group behind the worm, published the Shai-Hulud framework source to GitHub along with instructions and a paid "supply chain challenge" inviting copycats to use it (SecurityWeek, Datadog Security Labs). GitHub removed the original repos within hours. Forks proliferated faster than takedowns. The framework itself is a modular TypeScript/Bun toolkit for credential harvesting, supply-chain poisoning, and encrypted exfiltration, purpose-built for the exact pattern that just hit npm.
The next supply-chain worm doesn't have to be written from scratch anymore. The framework is a download away.
The same three layers (committed lockfile, release-age cooldown, lifecycle-script allow list) work against every variant in the framework.
