How attackers turn GitHub Actions' shared build cache into a supply chain weapon — real attack mechanics, the TanStack OIDC token extraction case study, and a complete hardening checklist to protect your CI/CD pipelines.
In May 2026, the TanStack ecosystem was compromised through a sophisticated attack that combined two vulnerabilities: a pull_request_target Pwn Request and GitHub Actions cache poisoning. The attacker published 84 malicious npm package versions without ever stealing npm credentials — they exploited GitHub Actions' own caching mechanism and an OIDC trust relationship to publish as an authorized maintainer.
This was not an isolated incident. The Mini Shai-Hulud worm campaign (April–May 2026) used similar cache poisoning techniques to compromise over 170 npm and PyPI packages. The Grafana Labs breach also exploited the Pwn Request vector. GitHub's own 2026 Actions security roadmap directly acknowledges cache poisoning as a critical threat requiring new platform-level protections.
But platform-level protections are months away. Right now, the security of your CI/CD pipeline depends on your workflow configurations. This guide covers how cache poisoning works, how the TanStack attack unfolded in detail, and — most importantly — how to audit and harden your own GitHub Actions workflows.
GitHub Actions provides a built-in caching mechanism through the
actions/cache action. It lets workflows save and restore directories
(like node_modules, ~/.cache/pip, or .next/cache)
across workflow runs to avoid re-downloading dependencies.
Each cache entry is identified by a key, typically composed of runner OS, dependency hash, and the branch name. The key determines which cache is restored:
actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
Here's the critical detail: cache scopes are determined by branch hierarchy.
Feature branches created off main can access caches from main.
This means if a workflow running in a fork's PR context writes to a cache that the base
branch can restore, the cache entry becomes a cross-trust-boundary weapon.
The actions/cache action, by default, writes cache entries scoped to the
branch where the workflow runs. But the restore-keys fallback logic can
pull cache entries from parent branches — including main. When an attacker
controls what gets cached from a fork PR workflow, they can poison the cache that
the base branch's release workflow will restore.
| Cache Scope | Who Can Write | Who Can Read | Risk Level |
|---|---|---|---|
Default branch (main) |
Workflows on main |
All branches derived from main |
Critical |
| Feature branch | Workflows on that branch | That branch only | Low |
Fork PR with pull_request |
Fork branch | Fork branch only | Safe |
Fork PR with pull_request_target |
Base branch context | Base branch + derived branches | Critical |
The attack requires three ingredients to succeed:
pull_request_target workflow that checks out and executes code from the forked PR.node_modules, pnpm store) using actions/cache with keys that overlap with the default branch.
The attacker submits a pull request to a public repository from a fork. The repository
has a workflow triggered by pull_request_target that checks out the PR
head and runs scripts from it. Because pull_request_target runs in the
context of the base repository, the workflow has access to GITHUB_TOKEN,
secrets, and the cache API.
# Simplified attack workflow
name: PR Check
on:
pull_request_target:
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
# Attacker-controlled: npm install fetches malicious deps
- run: npm ci
# Cache upload with base-branch scope
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
Once the malicious cache entry exists in the base branch's scope, any workflow that
uses actions/cache with overlapping restore keys can pull the poisoned
data. The release workflow — which typically has contents: write,
id-token: write, and access to npm/publishing secrets — becomes the
victim.
In the TanStack attack specifically, the cache poisoning was the entry point. Once the poisoned dependencies were restored in the release workflow, the attacker's code could inspect the runner's process memory space. GitHub Actions runners expose environment variables and process information to the running workflow, including:
id-token: write is set).GITHUB_TOKEN, secrets, and other sensitive configuration.The TanStack postmortem provides a minute-by-minute account of the attack.
pull_request_target.@tanstack/*.@tanstack/* packages published.pull_request_target for public repositories. Use pull_request instead.GITHUB_TOKEN permissions to read-only by default. Use permissions: {} and elevate explicitly.@v4 tags — they can be rewritten.I provide CI/CD security consultation as part of my web development services. Let's discuss your project.
I provide security audits for GitHub Actions workflows. Free initial consultation.