Skip to content

The TanStack npm compromise

A pull request that was never merged got TanStack's own release pipeline to publish malware, with valid provenance. This is the chain, one trust boundary at a time, and what to do if you installed it.

  • CVE-2026-45321
  • CVSS 9.6
  • Malicious versions
  • CISA KEV · 2026-05-27

In the drawing, the attacker's code drops through each layer and turns amber once the release job restores the poisoned cache. The dashed loop is the worm republishing.

The attacker's code dropping through five trust layersFive stacked planes descend toward the lower right: a fork's pull request, the GitHub Actions cache, the release job with its npm publishing token, the npm registry, and your machine. A pale line drops through the fork and the cache. Past the cache, which was shared with the fork's pull request, it turns amber and glows through the release job, the registry and your machine, then loops back up to the registry as the worm republishes. 84 malicious versions were published.fork · pull requestGitHub Actions cacherelease job · OIDC tokennpm registryyour machinecache shared with the fork PR84 malicious versions,then a worm

What happened

On 11 May 2026, between 19:20 and 19:26 UTC, an attacker published 84 malicious versions of 42 @tanstack/* packages. They never stole a password or an npm token: they poisoned a CI cache from a pull request, and TanStack's own release workflow did the rest. Installing a bad version ran a credential stealer that also tried to republish the victim's own packages.

Reported publicly 26 minutes after the first publish; all 84 versions deprecated within 1 h 43 min; npm removed every tarball within 4 h 35 min. This is not a bug to upgrade past: if you installed one, the machine that ran the install is compromised. Security vendors link it to the “Mini Shai-Hulud” campaign; TanStack's own posts do not use the name.1

Am I affected?

Only if something installed one of the malicious versions: a laptop, a CI runner or a build server. They were on npm on 11 May 2026 from 19:20 UTC until npm removed them, by 23:55 UTC. All 42 packages are from the TanStack/router monorepo: router, start (including @tanstack/start-*), devtools, adapters and plugins. Not affected: Query, Table, Form, Virtual, Store, DB, AI, Pacer, every other TanStack repo, and the @tanstack/start meta-package.4

Covers four of the 42 packages from the advisory; each had exactly two malicious versions. What matters is what was installed in the window: check lockfiles, caches and CI logs.

The four example packages and their bad versions
Malicious versions, publish times and the next real release
PackageMaliciousPublished (UTC, 11 May)Next release
@tanstack/history1.161.9 · 1.161.1219:20:39 · 19:26:141.162.0
@tanstack/react-start1.167.68 · 1.167.7119:20:42 · 19:26:161.168.0
@tanstack/react-router1.169.5 · 1.169.819:20:42 · 19:26:171.170.0
@tanstack/start-server-core1.167.33 · 1.167.3619:20:40 · 19:26:161.168.0

sources disagree The GHSA names the next patch number as “first patched” (for example react-start 1.167.72), but npm has no record of those versions; TanStack's next releases were minor bumps published together at 19:27 UTC on 15 May. Safe either way: a version published before 11 May 19:00 UTC (for most packages the last good one is from 15 March; history 1.161.6, for example), or a 15 May or later release. Sources: GHSA-g7cv-rxg3-hmpx; npm registry metadata.6

Do this now

  1. If a machine or CI runner installed one, treat it as compromised, and isolate it first. StepSecurity reports the payload creates an npm token whose revocation triggers a wiper.11
  2. Then rotate every AWS, GCP, Kubernetes, Vault, GitHub, npm and SSH credential it could reach, and review cloud audit logs from the install onward.
  3. Reinstall clean. Delete node_modules and the lockfile, then install a version from before 11 May 19:00 UTC or from 15 May onward. Set ignore-scripts while you do. The GHSA suggests inspecting a tarball with npm pack and tar, which run no scripts.4
  4. Remove persistence (vendor reports): hooks in .claude/settings.json, tasks in .vscode/tasks.json, macOS LaunchAgents, Linux systemd units. Block egress to *.getsession.org.
  5. If you maintain packages, check for publishes you didn't make. That is the worm stage.
Contents
  1. Field marks
  2. How it works
  3. Timeline
  4. Provenance
  5. Defenses
  6. Related
  7. Sources

A bad tarball has three tells. An infected machine has a few more.

In a package tarball

  • An optionalDependencies entry named @tanstack/setup, which is not a real npm package, pointing at a git URL.
  • A router_init.js of about 2.3 MB at the tarball root, left out of the manifest's "files" list.
  • A tarball far larger than its predecessor. StepSecurity's detector flagged a 3.7× size jump. vendor

On a machine that installed one

  • Outbound traffic to filev2.getsession.org or seed1–seed3.getsession.org, the Session messenger network the payload used to send stolen data out.
  • New hooks in .claude/settings.json (SessionStart) or .vscode/tasks.json (folderOpen), new LaunchAgents or systemd services. vendor
  • An npm token you did not create whose description warns against revoking it. vendor
  • Releases of your own packages that you did not publish: the worm stage.

Four trust boundaries, crossed one at a time.

This isn't a bug in TanStack's code that you patch by upgrading. The packages themselves were malware, so the question is what ran on your machines.

Every step used a feature as designed. The weakness was transitive trust: each link assumed the one before it was trusted. Fork code reached the base repository's cache; the cache reached the release job; the release job reached npm. Dashed boxes are untrusted.

  • Actions cacheFiles, such as a package-manager store, that GitHub Actions keeps between runs so installs are faster.
  • Cache keyThe name a cache entry is stored and looked up under, here derived from the lockfile's hash.
The TanStack attack chain across four trust boundariesTop: the attacker's untrusted fork with PR 7378 and a commit adding a 30,000-line payload. Crossing via pull_request_target into TanStack/router's GitHub Actions: the bundle-size workflow runs the PR's install and build and saves a poisoned pnpm store into the Actions cache under the main branch's key. The release workflow, which has id-token write permission, restores that cache; the planted code reads the OIDC publish token from the Runner.Worker process memory and publishes 84 versions of 42 packages directly to the npm registry with valid provenance. Bottom: on your machine or CI runner, npm install pulls the @tanstack/setup git dependency, whose prepare script runs router_init.js and then exits with an error; the payload steals credentials, sends them out, persists, and republishes the victim's packages back to npm.Attacker's forkzblgg/configuration: renamed so it hides in the fork listPR #7378WIP: simplify history buildopened 11 May 10:49 · closed 11:31commit 65bf499d+ ~30,000-line payloadforged “claude” author, not Anthropicpull_request_targetTanStack/router · GitHub Actionsbundle-size.ymlon: pull_request_targetchecks out the PR's coderuns pnpm install + buildActions cachescope: mainLinux-pnpm-store-<lockfile hash>1.1 GB · saved after the job endsrelease.ymlon: push to mainid-token: writerestores the pnpm storeRunner.Worker memory→ OIDC publish tokenread through /proctoken → direct publishnpm registry84 malicious versions · 42 packagesprovenance: release.yml@refs/heads/main19:20:39 and 19:26:14 UTCnpm installYour machine or CI runner@tanstack/setupgit URL dependencyan orphan commit in theTanStack/router fork networkprepare scriptrouter_init.js · ~2.3 MBan obfuscated payload runs,then exits with an error → droppedPayloadsteals · sends · spreadscloud, k8s, Vault, npm, GitHub, SSHsent out via getsession.orgrepublishes the victim's packages
Diagram of the TanStack attack chain. Focus it and use the left and right arrow keys to move between the eight steps listed after it; each step's text describes that part in full.

  1. 10 May · 17:16–23:29 UTC

    The attacker prepares a fork.

    An account named zblgg forks TanStack/router and renames the fork zblgg/configuration, so it doesn't show up when someone searches the fork list.

    A commit on the fork, under the forged identity claude <claude@users.noreply.github.com> (not Anthropic), adds a roughly 30,000-line bundled payload. Its message starts with [skip ci].1

    repo
    zblgg/configuration
    commit
    65bf499d…
    adds
    packages/history/vite_setup.mjs
    size
    ~30,000 lines
  2. 11 May · 10:49–11:11 UTC

    A pull request runs with the base repository's privileges.

    PR #7378, “WIP: simplify history build”, triggers bundle-size.yml. That workflow uses pull_request_target, a GitHub Actions trigger that runs in the context of the base repository and skips the approval normally required for first-time contributors.

    The job checked out the PR's code and ran pnpm install and a build, which runs the contributor's code. This pattern is called a Pwn Request. The job was meant to be read-only, but permissions: limits only the GITHUB_TOKEN.9

    trigger
    pull_request_target
    context
    base repo, TanStack/router
    approval gate
    skipped
    runs
    the PR's install + build
    permissions:
    limits GITHUB_TOKEN only
  3. 11 May · 11:29–11:31 UTC

    The build poisons the cache the release job will use.

    GitHub Actions saves caches in a step after the job ends, using a runner-internal token that permissions: doesn't restrict. A pull_request_target run shares the base repository's cache scope with pushes to main.13

    The payload wrote attacker files into the pnpm store and saved it under the exact key the release workflow computes. Two minutes later the PR was reset to an empty diff and closed. The cache stayed.

    scope
    refs/heads/main
    key
    Linux-pnpm-store-<lockfile hash>
    size
    1.1 GB
    written by
    post-job save, runner token
    after PR closed
    still there
  4. 11 May · 19:15–19:16 UTC

    Almost eight hours later, real releases restore it.

    A release run from a PR merged two days earlier is re-run, and a maintainer merges another PR, starting a second release run. Both restore the poisoned pnpm store “entirely as designed”.

    That puts attacker-controlled binaries on disk inside TanStack's trusted release job, and they run during the build and test phase.1

    runs
    25613093674 · 25691781302
    cache hit
    the poisoned key
    branch
    main
    attacker code
    runs during build/test
  5. The planted code reads the publish token out of memory.

    TanStack used npm trusted publishing: instead of a stored npm token, the release job asks GitHub for a short-lived OIDC identity token (that is what id-token: write allows), and npm accepts it as proof the publish comes from TanStack's workflow.

    The planted code found the runner's Runner.Worker process, read its memory through /proc and pulled the token out. TanStack says the script was the same one used in the March 2025 tj-actions/changed-files compromise.

    permission
    id-token: write
    token
    OIDC, short-lived
    read from
    Runner.Worker memory
    long-lived secret
    none needed
  6. 11 May · 19:20:39 and 19:26:14 UTC

    It publishes straight to npm, as TanStack.

    The malware exchanged the token with npm and uploaded packages directly. The workflow's own Publish step never ran: the payload had broken the tests. To npm, the uploads came from release.yml@refs/heads/main, so they carried valid provenance, the signed record of which workflow built a package.

    Two release runs restored the same cache, which is why each package got exactly two bad versions about six minutes apart.

    publisher
    TanStack/router release.yml
    ref
    refs/heads/main
    provenance
    valid
    versions
    84 across 42 packages
    Publish step
    skipped (tests failed)
  7. Installing a bad version runs the payload, then hides it.

    Each malicious manifest adds an optionalDependencies entry, a dependency that may fail to install without failing the install, named @tanstack/setup. It points at a git URL for an orphan commit in the TanStack/router fork network; GitHub serves those through the parent repository's URL, so no write access was needed.

    The package manager fetches it and runs its prepare lifecycle script, a command a package asks to run during install. That starts router_init.js, a ~2.3 MB obfuscated payload, then exits with an error so the optional dependency is silently dropped.4

    optional dep
    @tanstack/setup → git URL
    source
    orphan commit, fork network
    script
    prepare → router_init.js
    then
    exits with error, dropped
  8. It steals credentials and tries to spread.

    TanStack and the GHSA list what it takes: AWS and GCP metadata and secrets, Kubernetes and Vault tokens, ~/.npmrc, GitHub tokens and SSH private keys. It sends them over the Session messenger's file network, end-to-end encrypted, with no attacker-run server to block.

    Then it lists the victim's own npm packages and republishes them with the same injection. That makes it a worm: each infected maintainer becomes the next publisher. StepSecurity calls it “the first documented npm worm that produces validly-attested malicious packages”.11

    steal
    cloud, k8s, Vault, npm, GitHub, SSH
    send
    Session network (getsession.org)
    spread
    republish victim's packages
    persist
    .claude/, .vscode/ (vendor reports)
    revoke token
    reported wiper trigger

The numbered markers on the diagram match the steps.

Each is necessary for the attack; none alone is sufficient.

TanStack, npm supply chain compromise postmortem, May 2026

Eight quiet hours, then a fast response.

  • 7h 44min

    the poisoned cache sat unused before a release restored it.

  • 26min

    from the first malicious publish to the public report.

  • 1h 43min

    until all 84 versions were deprecated.

  • 4h 35min

    the longest any bad version stayed on npm.

Why it was noticed quickly

The payload broke the test suite, so the release runs failed loudly. A quieter attacker “could have published silently for hours longer”, the postmortem says.

Timeline from fork to response, 10 to 12 May 2026Act one runs from the fork at 17:16 on 10 May through the malicious commit at 23:29, the pull request at 10:49 on 11 May, the payload run at 11:11, the cache save at 11:29 and the PR closing at 11:31. The cache then sits dormant for 7 hours 44 minutes. Act two: the release run restores it at 19:15, malicious versions publish at 19:20 and 19:26, the public report lands at 19:46, the first deprecations at 20:19 and the last at 21:03. npm removes the tarballs between 22:13 and 23:55, and the global advisory appears at 00:12 on 12 May.Act 1 · setup and cache poisoning7 h 44 min dormantAct 2responseMay 11May 12Fork created, May 10 17:16Malicious commit, 23:29PR #7378 opened, 10:49payload runs, 11:11cache poisoned, 11:29; PR closed, 11:31npm removes tarballs 22:13–23:55global GHSA, May 12 00:1219:1021:1019:46 reported21:03 all deprecated19:20 and 19:26 publishes19:15 cache restored20:19 first deprecatedzoom on the hourof detonation
Fig. 1. Two acts. The top axis is to scale across 32 hours; the lower axis magnifies 19:10 to 21:10 on 11 May. Brown marks the attacker's two decisive moments. Source: TanStack postmortem (corrected 15 May), npm registry timestamps, GitHub advisory metadata.
Response clock compared with a one-day install cooldownMeasured from the first malicious publish: publicly detected at 26 minutes, first two versions deprecated at 59 minutes, all 84 deprecated at 1 hour 43 minutes, first removal from npm at 2 hours 53 minutes, last removal at 4 hours 35 minutes. A one-day minimum release age, pnpm 11's default, is 24 hours, several times longer than the whole exposure.04 h8 h12 h16 h20 h24 hPublicly detected26 minFirst 2 deprecated59 minAll 84 deprecated1 h 43 minFirst removed from npm2 h 53 minLast removed from npm4 h 35 min: the longest any version was livepnpm 11 default cooldownminimumReleaseAge: 1440 min (one day)
Fig. 2. The response clock against a one-day cooldown. Bars measure time from the first malicious publish (TanStack postmortem). With a one-day minimumReleaseAge, the pnpm 11 default, every malicious version was deprecated and removed before a default install would have accepted it. Before v11 the default was 0. A cooldown only works if your package manager enforces it: in the Nx Console follow-on, a contributor's 7-day setting silently did nothing on an outdated pnpm.10

We learned about the compromise from a third party.

TanStack, npm supply chain compromise postmortem, May 2026

Provenance proved where the packages came from. Not what ran inside.

  • Provenance provesWhich workflow, branch and run published it
  • It can't proveWhat code ran inside that workflow

Provenance answered its question truthfully. The packages really were built and published from TanStack/router's release.yml on main. What it cannot say is what code ran inside that job. StepSecurity called this “the first documented npm worm that produces validly-attested malicious packages”.11

TanStack's own summary: “npm provenance, SLSA, OIDC, and 2FA all worked as advertised and still didn't stop this attack.” And: “Provenance shouldn't be confused with innocence.”

It still paid off afterwards. Provenance pinned every bad version to an exact run, branch and time, which made scoping the incident fast.

[Trusted publishing] has no per-publish review. Once configured, any code path in the workflow can mint a publish-capable token.

TanStack, npm supply chain compromise postmortem, May 2026

Three publisher controls would each have broken the chain.

What would have broken the chain, mapped to the steps above. TanStack has since applied most of the publisher-side controls.

ControlEffect hereWhy
No pull_request_target that builds fork codeBreaks step 2Build untrusted code in pull_request; do privileged follow-ups in a separate workflow_run that uses only artifacts. TanStack removed pull_request_target entirely.
Cache isolationBreaks steps 3, 4Don't cache in release workflows at all (Adnan Khan, 2024); elsewhere restore without saving (actions/cache/restore). TanStack disabled the pnpm cache in its release pipeline.
id-token: write only in a minimal publish jobBreaks step 5A poisoned build step could not mint the token if the job that runs dependency code never has the permission. inference from the chain TanStack proposes checks that flag publishes from unexpected workflow steps.
Install cooldownProtects step 7The longest any version was live was 4 h 35 min; pnpm 11 defaults to one day (Fig. 2). Verify your package manager actually enforces the setting.
Install scripts off by defaultProtects step 7The payload ran from a prepare script. ignore-scripts, or an allow-list of packages permitted to run scripts, removes that path. npm v12 now blocks dependency lifecycle scripts unless allowed.
SHA-pinned actions, workflow linting, CODEOWNERS on .githubHardeningPart of TanStack's follow-up (zizmor as a planned required check). They raise the bar for workflow changes rather than break one step of this chain.2
LockfileFinds exposureThe GHSA's first step is to search lockfiles, caches and CI logs for the listed versions.
Trusted publishing, provenance, 2FADidn't stop itAll worked as advertised. The attacker never needed a maintainer's credentials.

Sources

Primary sources are the maintainer, advisory databases, the registry and government. Secondary sources are the discoverer's and researchers' own analyses.

Primary · maintainer

Primary · advisories, registry and government

Secondary · discoverer, researchers, press

Written from sources checked on 2026-09-30. Attribution of the wave to a named group, and its spread to other projects, are vendor claims not confirmed by TanStack or a government source, so they are described here only as such. Pull quotes are verbatim from the TanStack postmortem; the bracketed words are ours.