TanStack just proved, again, that a pretty badge is not a security model.

On May 11, 2026, 84 malicious npm versions across 42 @tanstack/* packages got published through the legitimate GitHub Actions OIDC trusted-publisher path. Not a stolen long-lived npm token. Not some greasy shell script from 2019. A real workflow, on a real repo, wearing a real trust badge, did the dirty work.

That is the part people keep failing to emotionally process. The pipeline was not “broken” in the cartoon sense. It was built so that one untrusted path could contaminate another trusted path, and then everybody acted shocked when the contamination turned into a publish event. Classic. Absolutely classic. The whole thing smells like a kitchen where somebody keeps saying “it’s fine, I wiped the counter.”

Here’s the cursed shape of it:

on:
   pull_request_target:
jobs:
   benchmark-pr:
      steps:
         - uses: actions/checkout@v6
           with:
              ref: refs/pull/${{ github.event.pull_request.number }}/merge
         - uses: TanStack/config/.github/setup@main
         - run: pnpm nx run @benchmarks/bundle-size:build

That is not a harmless benchmark job. That is a fork-controlled code path running in the base repo’s trust context, with cache writes, which later fed a privileged release workflow. The attacker poisoned the cache. The next trusted job restored it. Then malicious code ran during the publish pipeline, extracted the short-lived OIDC token from runner memory, and pushed malware straight to npm.

That chain matters more than the shiny words around it.

The badge is not the boundary

People love saying “we use SLSA” or “we use trusted publishing” like they just installed a steel vault door. No. You installed a nicer receipt printer.

Provenance tells you where the artifact came from. It does not magically tell you the artifact wasn’t generated by a workflow that got its pants pulled down by a poisoned cache and a bad trust split. A signed malicious package is still malicious. It’s just malicious with better stationery and a smoother onboarding flow.

The sick joke here is that the attacker didn’t need to defeat the npm publish step directly. The workflow did the stealing for them. That’s the whole problem. Once untrusted code can influence a later privileged job, the “later” job is no longer privileged in any meaningful sense. It’s just a delayed confession.

What actually went wrong

Three dumb ideas stacked on top of each other:

  1. pull_request_target was used where the repo later consumed outputs from that run.
  2. Shared cache state crossed the fork/base boundary like it was a free public sidewalk.
  3. The privileged release job had id-token: write, so when poisoned code executed, it could mint a publish-capable token in memory and use it before anyone had a chance to blink.

None of those is a novel cyberpunk exploit. They are boring, known footguns. Which is exactly why they keep winning. The internet’s favorite security strategy is to document a trap, name it clearly, and then leave it in production because “it only affects edge cases” and “the benchmark job needs cache hits.”

I have seen enough CI to know this is how it goes. Someone wants faster builds. Someone else wants prettier provenance. A third person wants to keep the release workflow “simple.” Then one day the pipeline is a little cathedral of mutually-assured trust, and some attacker strolls in through the side door with a forked repo and a very unserious expression.

on:
   pull_request:
permissions:
   contents: read
   id-token: write # only in the publish job, not the whole damn workflow
jobs:
   test:
      runs-on: ubuntu-latest
      steps:
         - uses: actions/checkout@v6
         - run: pnpm test

   publish:
      needs: test
      if: github.ref == 'refs/heads/main' && github.repository_owner == 'TanStack'
      runs-on: ubuntu-latest
      steps:
         - uses: actions/checkout@v6
         - run: pnpm publish

That is less magical. That is also the point. Security should be boring enough to disappoint product managers.

My unpopular take

Trusted publishing is good. OIDC is good. Provenance is good. None of that is the villain.

The villain is the fantasy that one trust badge can bless an entire pipeline, including the parts that execute code from strangers. The villain is cache reuse across security domains. The villain is the instinct to let pull_request_target wander around the repo like it owns the place. The villain is every optimization that quietly turns “build speed” into “future incident report.”

TanStack didn’t get owned because the maintainers were stupid. They got hit because modern CI is a haunted house where every room has a different authorization model and everybody keeps leaving the keys under the mat.

The fix is not mystical:

  • keep untrusted PR work and privileged release work physically separate
  • do not share caches across that boundary
  • pin actions to SHAs, not floating tags like a clown with a debit card
  • give id-token: write to the exact step that needs it, not the entire ceremony
  • stop treating pull_request_target like a reusable tool instead of an emergency-only loaded weapon

If your pipeline can be turned into a publish path by a fork, your pipeline is not hardened. It is just well-decorated.

And if your response to that sentence is “but our SLSA badge is still green,” congratulations: you have understood absolutely nothing and should probably sit down before you hurt yourself.