On the evening of May 11, 2026, an attacker published 84 malicious versions across 42 @tanstack/* npm packages inside a six-minute window. If you ran pnpm install or npm ci against a fresh lockfile during that window, you pulled the payload. If you didn't, you got lucky, and luck is not a security control.

So this post is about the controls. The TanStack team has shipped a thorough postmortem, the incident is now CVE-2026-45321 with a CVSS of 9.6, and the same campaign torched @mistralai/*, @squawk/*, Guardrails AI, and a pair of PyPI packages on the same day. SafeDep tallies 170+ npm packages and 404 malicious versions across both ecosystems. The threat actor is TeamPCP, the same crew behind the LiteLLM PyPI compromise in March. The cadence is monthly now.

I'll walk through the attack chain, the three pnpm settings you should turn on today (with the rough equivalents for npm, Bun, and Yarn), and then the bigger question: why I think you should start pulling your dependencies into source control, the way Meta and Google have been doing for fifteen years.

The Attack Chain: Three Boring Mistakes Stacked Together

How does a project with maintainer 2FA, signed commits, and SLSA provenance end up publishing 84 malicious versions in six minutes? By chaining three mistakes that every public GitHub Actions monorepo probably has at least one of.

TanStack attack chain: pwn request, cache poisoning, OIDC theft. STAGE 1 / PWN REQUEST Attacker fork commit prefixed [skip ci] + 30k-line vite_setup.mjs PR opens against main bundle-size.yml trigger: pull_request_target checks out PR tree runs pnpm install (base perms) STAGE 2 / CACHE 1.1 GB poisoned cache key matches release.yml saved, waits 8 hours STAGE 3 / OIDC THEFT Maintainer pushes to main release.yml fires actions/cache restore poisoned tree loaded Runner.Worker memdump scan /proc/*/cmdline read process memory extract OIDC publish token npm publish x84 19:20 - 19:26 UTC 42 packages valid SLSA provenance
// the three-stage trust chain — none of these are zero-days

The first mistake was the pull_request_target trigger on a workflow that ran untrusted fork code. That's the classic Pwn Request pattern, and it gave the attacker code execution with base-repo permissions just by opening a PR. The second mistake was trusting the GitHub Actions cache as if it were namespaced. It isn't. A poisoned cache from a fork-triggered run sits in the same key space as the cache the release pipeline restores, and the release pipeline restored it.

The third mistake is the one that should worry every npm maintainer reading this. When release.yml ran with the poisoned cache loaded, the attacker's binaries scanned /proc/*/cmdline for the GitHub Actions Runner.Worker process, dumped its memory, and pulled out the short-lived OIDC token that npm uses to authenticate publishes. They then published directly to the registry, bypassing the workflow's own publish step entirely. Because the token was valid and freshly minted, the malicious versions came with valid SLSA provenance.

SLSA provenance did not save you here

If your supply chain story is "we only install packages with verified provenance," this attack walked through that. The token doing the publish was the real publisher's token. The build attestation was real. The compromise happened inside the trusted CI environment, with the trusted credential, in the trusted pipeline. Provenance is useful, but it is not a stand-alone control.

The payload itself is exactly what you'd expect: a ~2.3 MB obfuscated router_init.js that runs at install time, vacuums AWS keys, GCP service account JSON, kubeconfigs, Vault tokens, GitHub PATs, npm tokens, SSH keys, and shell history off the host, then exfiltrates over the Session Protocol CDN and GitHub's own GraphQL API to blend with normal CI traffic. The Semgrep, StepSecurity, and Wiz writeups all dig into the payload mechanics if you want the deep dive.

What Would Have Saved You on the Consumer Side

The publisher-side fixes are TanStack's problem, and they've already shipped them: kill pull_request_target, scope OIDC tokens narrowly, isolate the release runner. The question for the rest of us is simpler. If you'd run pnpm install at 19:23 UTC on May 11, what configuration would have spared you the breach?

The answer is a small set of package manager settings that have existed for a while and that almost nobody had turned on. pnpm 11 (released April 28, 2026, two weeks before the TanStack publish) flipped most of them on by default. If you're still on pnpm 10, or on npm or Yarn, you have to opt in. Do it today.

1. minimumReleaseAge — let other people get owned first

The single highest-value setting in pnpm 11 is minimumReleaseAge. It refuses to resolve any version of any package that was published less than N minutes ago. The pnpm 11 default is 1440, or 24 hours.

pnpm-workspace.yaml
# Refuse versions published in the last 3 days.
# Bump to 10080 for a one-week delay if you can stomach it.
minimumReleaseAge: 4320

# Escape hatch: dependencies you trust to install immediately.
# Use sparingly. Anything in here is *not* protected.
minimumReleaseAgeExclude:
  - "@my-org/*"

This is not clever. It's just patience. The TanStack payload was live for about 20 minutes before external detection, and the bad versions were yanked within hours. Anyone running minimumReleaseAge: 1440 never resolved the malicious versions, because by the time their lockfile was allowed to bump, the bad versions were gone.

Every modern registry compromise has the same shape. The attacker publishes. Somebody notices. The registry yanks. The window where the bad version is the latest version is almost always under 24 hours. The one-week setting is better, and the only thing it costs you is a week of not having the new features — which you weren't going to ship to production this week anyway.

2. strictDepBuilds + allowBuilds — kill install-time execution

This is the setting that shuts down most install-time payloads, including the TanStack one, on the consumer side. Postinstall scripts and native build steps are where the malicious code runs. If your package manager refuses to run those scripts unless you've explicitly allowlisted the package, the payload never executes.

pnpm-workspace.yaml
# Fail the install if any transitive dep wants to run a build script
# that isn't explicitly allowed. Hard gate, not a warning.
strictDepBuilds: true

# The ONLY packages allowed to run install-time scripts.
# Everything else is a silent no-op.
allowBuilds:
  - "esbuild"
  - "@swc/core"
  - "sharp"
  - "node-gyp"

pnpm 10 disabled postinstall scripts by default and printed a warning. strictDepBuilds upgrades the warning to a hard failure: if any package in your tree wants to run a build script and isn't in allowBuilds, the install errors out and tells you which package. You then make a deliberate choice to allow it, or you don't, and the package installs without its scripts ever firing.

For the overwhelming majority of npm packages, "doesn't execute its install scripts" is functionally identical to "does execute them" — those scripts are no-ops, telemetry pings, or unnecessary native builds. The packages that genuinely need install-time builds (native bindings, ABI shims) are a short, well-known list. Allowlist them by hand and revisit the list when you add a real dependency.

3. blockExoticSubdeps — close the git/tarball back door

This one isn't tied to the TanStack incident, but it's the next hole. npm and pnpm both let a dependency declare its own dependencies as git URLs or arbitrary tarball URLs. A package you trust can transitively pull in {"some-helper": "git+https://github.com/random-person/lib.git#main"}, and that "package" is whatever the URL serves today.

pnpm-workspace.yaml
# Refuse transitive deps that aren't on the registry.
# Default in pnpm 11. Set it explicitly anyway so it's auditable.
blockExoticSubdeps: true

# Bonus: refuse silent trust downgrades.
trustPolicy: "no-downgrade"

Every incident report I read ends with a footnote about "three more packages doing weird things with git URLs." Cut it off at the package manager. The registry is the only ingress.

Minimum pnpm config, copy-pasteable

pnpm-workspace.yaml — the bare minimum
minimumReleaseAge: 4320         # 3 days
minimumReleaseAgeExclude:
  - "@my-org/*"
strictDepBuilds: true
blockExoticSubdeps: true
trustPolicy: "no-downgrade"
allowBuilds:
  # add only what your build actually requires
  - "esbuild"

What About npm, Bun, and Yarn?

pnpm is ahead of the field on this, but every package manager has at least a partial answer. The shape of the controls is the same everywhere: turn off install scripts globally, allowlist what you actually need, and gate by publish age either in the package manager or in CI.

Control pnpm 11 npm 10+ Bun 1.x Yarn 4
Block install scripts by default on (allowBuilds) --ignore-scripts trustedDependencies enableScripts: false
Minimum release age 1440 default none native none native none native
Block exotic subdeps (git/tarball) on by default no no constraints engine
Explicit build allowlist allowBuilds global flag only trustedDependencies plugin
Lockfile signed by registry opt-in opt-in no opt-in
// matrix of supply-chain controls across package managers (May 2026)

For npm, the simplest move is ignore-scripts=true in .npmrc at every repo root, plus a CI gate that lints the lockfile against publish-age timestamps before npm ci. Socket, Snyk, and StepSecurity all ship this as a pipeline policy. For Bun, set an empty trustedDependencies array in package.json and revisit it the first time a real install errors out. For Yarn 4, flip enableScripts off in .yarnrc.yml.

The Bigger Pivot: Pull Your Dependencies Into Source Control

The settings above buy you time and remove the easiest exploit primitive. They do not solve the underlying problem. Every pnpm install is still an unaudited code drop from a public registry into your build. Every CI run, every developer laptop, every container build. You have no idea what changed between yesterday's lockfile and today's beyond the version numbers. The lockfile pins a hash. It does not pin a diff.

Want to see what the alternative looks like? It's what Meta has been doing for fifteen years.

Registry-resolved vs vendored dependency flow. REGISTRY-RESOLVED (TODAY) your repo pnpm install at build time public registry mutable, unaudited node_modules/ VENDORED (META PATTERN) your repo + third-party/ reviewed diff at upgrade human eyes on the bytes build (offline) no network for deps artifact
// two trust models for the same install step

Inside Meta's monorepo, third-party dependencies don't live in a node_modules/ resolved at install time from a public registry. They live as checked-in source code in third-party/, reviewed when they were first added, diffed when they're upgraded, owned by humans whose names are in the OWNERS file. When a CVE drops in a package, an engineer opens a diff against the vendored copy in the monorepo, reviews it, and lands it. The build does not talk to npm. The build talks to the monorepo.

This sounds extreme until you realize it's how Linux, Go, and most large Rust projects already operate. Go pushed the industry back toward vendoring a decade ago with go mod vendor. Rust has cargo vendor. The Linux kernel vendors. The JavaScript ecosystem is the outlier here, not the rule, and the cadence of registry compromises is making that look like the unforced error it always was.

Vendoring is not a silver bullet

If you vendor @tanstack/react-router on May 10 and then bump the vendored copy on May 11 by blindly re-running an import script against npm, you pull the malicious version straight into your repo. The defense is the review step: someone looks at the diff, sees a 30,000-line obfuscated router_init.js that was not there yesterday, and rejects the bump. That review only happens if vendoring is structured as "manually approved import," not "git-add the new node_modules/." Treat the upgrade as the security event, not the install.

What vendoring actually buys you:

The tooling is workable. Yarn Berry's Zero-Installs mode commits the resolved tree into the repo. pnpm has pnpm fetch and offline mirrors. For non-trivial projects, you write a small script that re-vendors from a private mirror at upgrade time and runs a diff against the previous tree. It isn't glamorous. It's also not hard.

I'm starting to do this on my own projects this week. The package managers aren't evil — pnpm in particular is doing the right thing with the v11 defaults — but I no longer trust the registry-to-build path as a primitive. LiteLLM in March, Bitwarden CLI in April, TanStack and Mistral yesterday. The pattern is constant and the cadence is accelerating.

What to Do This Week

  1. Grep your lockfiles against the IOC list. TanStack's postmortem, StepSecurity, and Snyk all publish package-and-version IOCs. If you find a hit, rotate every credential reachable from the affected install host — npm tokens, GitHub PATs, AWS keys, kubeconfigs, Vault tokens, SSH keys, the lot.
  2. Upgrade to pnpm 11 today. Set minimumReleaseAge: 4320, strictDepBuilds: true, write your allowBuilds. If you can't move to pnpm 11, set ignore-scripts=true in .npmrc and add a CI step that fails on packages younger than your threshold.
  3. Audit your pull_request_target workflows. Anywhere you check out PR code with base-repo permissions, you have a Pwn Request waiting. Drop to pull_request, or gate execution on github.event.pull_request.head.repo.full_name == github.repository.
  4. Stop trusting GitHub Actions cache for security-bearing inputs. The cache key namespace is shared between forks and base. Treat anything coming out of actions/cache as attacker-controlled.
  5. Scope your npm publish tokens narrowly. Trusted-publisher OIDC tokens scoped to a single package, not a personal token with org-wide write. The TanStack token could publish anything under the scope.
  6. Start the vendoring conversation. Not next quarter. This week. Pick one critical dependency, vendor it as a pilot, get the muscle.

The Trust Pivot

The npm and PyPI registries have been the most consequential pieces of infrastructure in software engineering for fifteen years, and they were built on a trust model that no longer fits the threat model. The model was: the maintainer is a real person, the package they publish is the code they wrote, the registry hands it to you faithfully. Each of those three legs has been broken on camera in the last six months. Maintainer machines get owned (LiteLLM). Maintainer publish credentials get stolen from their own CI (TanStack). Lookalike packages get published into the same namespace as the real ones.

The defenses we've been bolting on — provenance, signing, 2FA on publish — assumed the failure happens before publish. The TanStack attack happened at publish, with valid provenance and a valid token, and they all failed open. The pnpm v11 defaults are the right kind of pivot: less "verify upstream is good" and more "assume upstream is bad and minimize what code execution costs me." That framing has to be the new baseline. Wait before you install. Don't run scripts you didn't allowlist. Don't accept code from anywhere but the registry. Don't trust the registry to be uncompromised. And, increasingly, don't trust the registry to be in the build path at all — vendor what you depend on, review the diffs, own the code.

None of this is free. The whole point of npm was that it was free. The bill on that has been arriving in twenty-minute publish windows roughly once a month, and you pay it in incident response hours, credential rotations, and customer-facing apologies. The cheapest version of paying it is the boring one. Turn on the three settings. Write the allowlist. Start moving the dependencies you actually depend on into your own repo.