TanStack npm Compromise: The Release Pipeline Was the Attack Surface


On May 11, 2026, the TanStack team published what every open-source maintainer hopes never to write: a detailed postmortem for a real npm supply-chain compromise.

The incident was not a simple stolen-token story. It was more interesting, and more worrying, because several controls that usually sound reassuring were already present. The packages were published through npm trusted publishing. The relevant GitHub workflow used OIDC. The risky work was supposed to run with read-only permissions. None of that was enough once an attacker found a path through the release pipeline itself.

According to the official TanStack postmortem, an attacker chained a pull_request_target workflow issue, GitHub Actions cache poisoning, and OIDC token extraction from runner memory. The result was 84 malicious versions across 42 @tanstack/* packages. The versions were published around 19:20 and 19:26 UTC, detected externally within roughly half an hour, deprecated, and investigated in public.

This is the shape of modern package compromise: not just “someone got phished,” but “the automation trusted one boundary, the cache crossed another, and the registry accepted the final artifact because the publish identity looked legitimate.”

What Actually Happened

The public report began in TanStack Router issue #7383. Security researcher carlini reported that several latest TanStack package releases contained a suspicious optionalDependencies entry pointing at an orphan commit:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

That dependency was not normal package structure. It caused npm to fetch source from GitHub and run a prepare script. The script executed an obfuscated payload file named router_init.js, roughly 2.3 MB, hidden in affected tarballs. Because the dependency was optional and the script failed after running, installation could continue while the malicious side effect had already happened.

The payload was designed to harvest high-value credentials from developer machines and CI runners: npm credentials, GitHub tokens, SSH keys, cloud metadata, Kubernetes service account tokens, Vault tokens, and local configuration files. Independent tracking from Aikido later described this as part of a broader Mini Shai-Hulud wave that had expanded beyond TanStack into other npm package groups.

The compromised TanStack packages were not obscure one-off uploads. They included Router and Start-related packages that real applications can pull into local development, CI, and release workflows. That matters because this class of malware does not need production runtime execution. A single install in a credential-rich environment is enough.

The Attack Chain

The key lesson is that three separate weaknesses had to line up.

First, a pull_request_target workflow ran in the security context of the base repository while checking out and building code influenced by a forked pull request. This pattern is often called a “pwn request” because pull_request_target is safe only when it is used for trusted metadata operations, such as labeling or commenting, not for running untrusted code from the pull request.

Second, the workflow used GitHub Actions caching. The attacker did not need the normal GITHUB_TOKEN to have write permissions. The cache save path uses runner-internal behavior. So even if the job appeared read-only from the perspective of repository permissions, it could still save poisoned cache contents under a key later restored by a trusted workflow.

Third, the release workflow legitimately had id-token: write so it could publish to npm through OIDC trusted publishing. Once the poisoned cache was restored during release, attacker-controlled code executed inside the trusted runner. From there, the attacker extracted an OIDC token from runner memory and used it to make direct publish requests to npm.

That is the uncomfortable part. Trusted publishing reduced the risk from long-lived npm tokens, but it did not prove that the bytes being published were safe. The publish identity was valid. The workflow was the thing that had been turned.

Why The Cache Boundary Matters

CI caches are usually treated as performance infrastructure. They should be treated as a security boundary.

A dependency cache can carry compiled artifacts, package manager state, postinstall side effects, and toolchain binaries from one job into another. If untrusted code can write a cache entry and trusted release code can restore it, the cache becomes a bridge between two worlds that were supposed to stay separate.

In the TanStack case, the poisoned data targeted the pnpm store key the release workflow would later compute. The release workflow restored the cache as designed. The compromise was not that cache restore “malfunctioned”; it was that the trust model around cache writers and readers was too broad.

For teams using GitHub Actions, this should change how actions/cache is reviewed:

  • Caches written by pull-request workflows should not be read by release workflows.
  • Cache keys should include trust context, not just lockfile hashes.
  • Release jobs should prefer fresh dependency installation over reusing state touched by untrusted jobs.
  • Any workflow that uses pull_request_target should be audited as privileged code.
  • Third-party actions should be pinned to immutable SHAs where practical.

The point is not to stop using cache. The point is to stop pretending cache is only a speed feature.

Why OIDC Did Not Save The Release

OIDC trusted publishing is still better than storing a long-lived npm token in a repository secret. It narrows the blast radius of token theft and binds publishing to a known workflow identity.

But OIDC answers a specific question: “Is this publish request coming from the expected workflow identity?” It does not answer a different question: “Was the runner already compromised before it requested the publish identity?”

Those are not the same problem.

If malicious code runs inside a job that is allowed to mint an identity token, the attacker can try to use that identity. In this incident, the official postmortem says the attacker extracted the OIDC token from runner memory rather than relying on the normal publish step. That bypassed the intended release command while still using the trust granted to the workflow.

So the control should be framed correctly:

  • OIDC reduces static secret exposure.
  • OIDC does not make a compromised runner safe.
  • OIDC does not validate package contents.
  • OIDC does not replace isolation between untrusted builds and trusted releases.

The practical response is to make release jobs boring and isolated. They should not restore artifacts from untrusted jobs. They should not run arbitrary fork code. They should minimize install scripts. They should publish from a clean checkout and a narrow dependency path.

Detection Worked, But It Was External

TanStack’s public response was fast, but the initial detection came from outside the project. The GitHub issue was opened with a concrete package fingerprint, affected package examples, and suggested verification steps. Socket also contacted the maintainers as the war room started, and other security vendors tracked the wider campaign.

That is a useful reminder for maintainers: assume you will not be the first person to notice your own compromise. Make it easy for outsiders to report precise findings, and make it easy for maintainers to act without debate.

The strong parts of the response were clear:

  • The issue stayed public while the team investigated.
  • Maintainers quickly removed broad push permissions during triage.
  • Affected package versions were identified and deprecated.
  • Cache entries were purged.
  • The vulnerable workflow path was hardened.
  • The team published a detailed root-cause postmortem the same day.

This is what good incident communication looks like under pressure. It did not hide the embarrassing details, and it did not pretend the first fix was the whole fix.

What Downstream Users Should Do

If your project installed affected TanStack versions during the exposure window, treat the relevant machine or runner as compromised until proven otherwise.

Start with dependency inventory. Check lockfiles, package manager caches, CI logs, artifact builds, and any internal package mirrors. Do not only check direct dependencies. A transitive dependency can still bring the package into an install path.

Then rotate secrets from environments that may have run the payload. That includes npm tokens, GitHub tokens, SSH keys, cloud credentials, deployment keys, Kubernetes service account tokens, and secrets exposed to CI jobs. Rotate from a clean machine. If a developer workstation may have executed the payload, do not do incident response from that workstation.

Also check for persistence. The public GitHub issue and later community comments discussed possible background services and token monitors. Exact indicators can change as researchers finish analysis, so rely on current advisories from TanStack, npm, Socket, Aikido, and your own security tooling rather than a stale one-liner copied into a chat window.

Finally, review whether your package manager installed lifecycle scripts at all. Many teams can run normal CI dependency installation with scripts disabled and only enable scripts for audited packages or build stages that truly need them.

What Maintainers Should Change

The maintainer lesson is not “never use GitHub Actions” or “never use npm.” The lesson is narrower and more useful: release workflows deserve a stricter threat model than normal CI.

Audit every pull_request_target workflow first. If it checks out pull-request code, runs a package manager, builds, tests, benchmarks, or executes project scripts, assume it can become an entry point. Move untrusted execution to pull_request, and reserve pull_request_target for base-repo-only tasks.

Separate caches by trust level. A cache written by forked code should not be readable by a release job. A cache written by a benchmark job should not be trusted by publishing. If that costs a few minutes, pay the cost in release workflows.

Treat trusted publishing as one layer, not the whole release defense. Pair it with clean runners, minimal permissions, reproducible release inputs, provenance checks, and package diff review. A valid OIDC publish from a compromised job is still a compromised publish.

Add package-content monitoring. Watch for unexpected lifecycle scripts, unexpected git dependencies, new large obfuscated files, package files not present in the repository, and sudden changes to optionalDependencies. In this incident, the fingerprint was visible in the published package metadata. The problem was time-to-detection, not impossibility.

Build an emergency playbook before the incident. It should cover who can deprecate npm versions, who can contact registry security, who can purge GitHub caches, who can disable publishing, who can rotate maintainer permissions, and where public updates go.

The Larger Pattern

Mini Shai-Hulud is not just another npm scare story. It shows that attackers are adapting to the defenses maintainers added after older incidents.

When maintainers adopted 2FA, attackers moved toward tokens, phishing, and CI secrets. When projects moved publishing into CI, attackers looked at workflows. When projects adopted OIDC trusted publishing, attackers targeted the runner before the token was minted. When security teams watched published packages, attackers optimized for short windows and self-propagation.

The next useful defense will come from reducing ambient authority in developer and CI environments:

  • Fewer install-time scripts.
  • Fewer secrets exposed to general build jobs.
  • Fewer shared caches across trust boundaries.
  • Fewer release jobs that depend on mutable state.
  • More package diffing before promotion.
  • More internal mirrors with quarantine windows for new versions.

The TanStack compromise is worth studying because it was not a cartoonishly negligent setup. It was a real open-source release system with modern practices, and it still had a path from forked code to npm publish. That is exactly why the incident matters.

Security work often fails when teams ask, “Which single control would have prevented this?” A better question is: “Which boundaries did this control assume, and can any automation cross them?”

In this case, the answer was yes. The pull request crossed into the base repository cache. The cache crossed into the release runner. The runner crossed into npm. Once you see that chain, the fix is not one magic switch. It is making each boundary explicit, narrow, and hard to reuse by accident.