Postmortem: TanStack npm supply-chain compromise
How an attacker chained pull_request_target abuse, GitHub Actions cache poisoning, and in-memory OIDC token extraction to publish 84 malicious npm packages in six minutes.

The attacker didn’t write novel code. They copied a verbatim Python memory-dump script — attribution comment included — combined it with a two-year-old cache-poisoning technique and an even older “Pwn Request” pattern, and used that combination to publish 84 malicious npm packages in six minutes. What made it dangerous wasn’t novelty; it was composition.
Source
Postmortem: TanStack npm supply-chain compromise
By: TanStack
Published: May 11 2026
Type: Engineering article
The most complete public postmortem I’ve seen for a GitHub Actions supply-chain attack. The timeline is verified and corrected, the root-cause analysis explains exactly why each of three vulnerabilities was necessary, and the open questions section is genuinely honest about what the team still doesn’t know.
Three trust boundaries, one chain
The attack required three separate vulnerabilities in sequence, and the reason each one was necessary is that each crossed a different trust boundary that the others couldn’t reach alone.
The first boundary was between a pull request fork and the base repository. GitHub’s pull_request_target trigger exists so maintainers can do trusted things — post status comments, apply labels — in response to PRs from forks, without giving those forks base-repo write access. The workflow author was aware this was a sensitive pattern and tried to split responsibilities: a benchmark-pr job that ran fork-controlled code, and a separate comment-pr job that did the trusted posting. The split was correct in principle but missed one detail: actions/cache@v5’s post-job save step uses a runner-internal token, not the workflow’s GITHUB_TOKEN. Setting permissions: contents: read on the job doesn’t block cache writes. A fork-controlled job running under pull_request_target can write to the base repository’s cache because the cache write path doesn’t check workflow permissions at all.
The attacker exploited this precisely. They committed a ~30,000-line obfuscated JavaScript payload into a renamed fork of TanStack/router — called zblgg/configuration to stay off fork-list searches — and opened a PR. The bundle-size.yml workflow fired automatically: no first-time-contributor approval gate applied under pull_request_target. That workflow ran pnpm install plus a build step against the fork’s code, which executed the payload. The payload wrote its poisoned node_modules back into the pnpm store under the exact cache key that the production release.yml workflow would look up on the next push to main: Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}. That hash is deterministic given the lockfile, which is committed to the base repo, so the attacker just had to match it. The PR was then force-pushed back to the current main HEAD and closed, making the visible diff a no-op. The cache poison persisted invisibly.
That is the second boundary: between a PR-triggered workflow and a production release workflow. Four hours later, a legitimate commit to main triggered release.yml, which dutifully restored the cache entry — along with the attacker’s payload now sitting inside the pnpm store.
The third boundary was between code running on the runner and npm’s registry. release.yml declares id-token: write because TanStack uses OIDC trusted publishing, which eliminates the need to store a long-lived npm token. The attacker-controlled binaries, now on disk as part of the restored store, located the GitHub Actions Runner.Worker process via /proc/*/cmdline, read /proc/<pid>/maps and /proc/<pid>/mem to dump the worker’s memory, and extracted the OIDC token that the runner had minted lazily in-process. With that token, they POSTed directly to registry.npmjs.org — not through the workflow’s own Publish Packages step, which was skipped because the tests had failed. The publish came from attacker code running during the test and cleanup phase. The OIDC mechanism had no way to distinguish this from a legitimate publish; the token was valid, minted by the correct workflow, for the correct scope.
Across 42 packages and 84 versions, the publish window was six minutes. The malware also self-propagated: it enumerated the victim’s other maintained packages via the npm registry search API and republished each with the same injection, meaning the blast radius wasn’t bounded to TanStack — it extended to any package any maintainer on that scope maintained. Exfiltration ran over the Session/Oxen messenger file-upload network, end-to-end encrypted with no attacker-controlled C2, which meant IP or domain blocking was the only available network mitigation.
Detection was external. A researcher at StepSecurity opened a GitHub issue with full technical detail 20 minutes after the first malicious publish, which put the team into incident response. All 84 versions were deprecated within 103 minutes of the first publish. npm removed the tarballs server-side within four and a half hours. The team was lucky on two counts: the payload broke the tests, making the failing workflow loud rather than silent, and the payload code contained a verbatim attribution comment from prior published research, which made IOC matching faster. A more careful attacker who didn’t break tests and stripped the attribution could have gone undetected for hours longer.
The consistent thread in the “what could have been better” section is instrumentation: no internal alerting fired, no monitoring caught a publish from an unexpected code path, and no automated audit had flagged the pull_request_target pattern despite it being documented as dangerous since at least 2021. Each of those is an operational gap rather than an architectural one — the tooling to detect all three exists.
My take
Each component of this attack was introduced to solve a real problem. pull_request_target exists because you need trusted context to post PR comments. The shared cache exists for build speed. OIDC exists to eliminate long-lived secrets. None of them looked dangerous in isolation, and a point-in-time security review of any one component would likely clear it. The vulnerability only emerged from their composition across trust boundaries that no single reviewer was looking across simultaneously. This is the defining characteristic of systemic supply-chain risk: the components are fine; the interfaces between them are not. The only mitigation is treating the composition as the unit of review, not the individual pieces.
Where this is useful
- Auditing CI/CD pipelines for
pull_request_targetworkflows that check out fork code and also restore cache — that pairing is the vulnerable combination - Understanding why “no stored tokens” doesn’t mean “no credential theft risk” when OIDC tokens are minted in runner memory with
id-token: writeset on the job - Designing cache scope and key policies so that PR-triggered workflows can’t overwrite entries that production workflows will restore on the next push to
main - When NOT applicable: routine application-layer security reviews that don’t include CI/CD infrastructure — the entire attack surface here is in the build and publish pipeline, not application code
Apply this: Search your repositories for
pull_request_targetworkflows that combineactions/checkoutwith a PR ref and a cache restore step — that’s the exact pattern to audit first.
What I want to remember
Remember: An OIDC token isn’t safe just because it’s ephemeral — once attacker code runs on the same runner process, the token can be read from memory before the process exits, and with
id-token: writeset, any code path in the workflow can mint a publish-capable token regardless of which step the workflow’s own publish logic lives in.