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. Six minutes. Eighty-four versions. 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. Every TanStack package family the attackers compromised, I depend on. 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 are the same three I have been writing about and pushing into every project I touch.
This post is the receipt.
What Mini Shai-Hulud actually did
The TanStack postmortem is worth reading in full, and Snyk's writeup has the cleanest forensic timeline I have 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 did not happen until the TanStack team got paged and reacted, which took longer than the publishing window itself.
This is the new face of npm supply chain attacks. Not a compromised developer. Not a typosquat. A worm that exploits the build infrastructure of the package itself, publishes faster than humans can review, and reaches every installer in the world the moment the registry mirrors catch up.
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. The dependency tree pulls 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 is not a hypothetical. It is 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 does not 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 does not 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 were not in my lockfile. Pnpm refused to fetch them. End of story.
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 is 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 is 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 do not 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 is 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 powerful 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 cannot run anything on your machine without you noticing.
Defense in depth
Each of these three defenses solves a different problem. They are not interchangeable. The lockfile assumes you have already installed once and have not 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.The Mini Shai-Hulud worm needed every layer to fail. It needed me to run pnpm install during the six-minute window. It needed the lockfile to be missing or ignored. It needed the cooldown to be unconfigured. It needed the postinstall script to actually run. It got none of them. The attack was airtight in theory and inert against the actual project.
This is what defense in depth means in package management. Not one strong control. Several adequate controls layered such that any single failure does not produce a compromise.
What I changed afterwards anyway
The defenses worked. The lockfile saved me. The cooldown would have saved me even if the lockfile had not. The allow list would have neutralized the payload even if both of the first two had failed. There was no actual exposure.
I still changed 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 are not even opened for versions younger than a week. I pinned the registry URL in .npmrc so that an injected user-level .npmrc cannot 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 do not control.
This is the right mindset for supply chain hardening. You assume your defenses will be tested. You assume the test will come at the worst possible moment. You assume the attacker is more patient than you are. And you build the kind of project that does not require you to be 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 is 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 are on pnpm. Pick a value between one day and one week and put it in
.npmrc. UseminimumReleaseAgeExcludefor the rare hotfix you cannot wait on. If you are not 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 cannot. - Add Dependabot or Renovate cooldowns so update pull requests are not opened for fresh versions. The cooldown windows should match (or be shorter than) your package manager's release-age setting.
None of these are exotic. None of them require new infrastructure. None of them cost anything to operate. Together they form a defense that the entire Mini Shai-Hulud playbook does not get past.
Lock your dependencies
I dodged this one because the defenses I have been pushing on every project were already in place when the attack landed. The site never had a chance to install the malicious code, and even if it had, the malicious code had no way to execute. None of that was luck. It was configuration.
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 do not 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.
Lock your dependencies. Make the frozen install the default. Configure the cooldown. Audit your script allow list. And do it today, not on the day of the next attack.
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" is not being written. It is being downloaded.
The defenses do not care. The same three layers, committed lockfile, release-age cooldown, lifecycle-script allow list, work against every variant in the framework. Configure them today.
