GitHub Actions Cache Poisoning: CI/CD Security Guide 2026
Security Guide · Updated 2026

GitHub Actions Cache Poisoning:
CI/CD Security Guide 2026

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.

Oleg Maximov May 21, 2026 14 min read

Introduction

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.

How GitHub Actions Caching Works

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.

Cache Key Structure

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-

The Trust Model Problem

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 Cache Poisoning Attack Mechanism

The attack requires three ingredients to succeed:

  1. A misconfigured trigger — A pull_request_target workflow that checks out and executes code from the forked PR.
  2. Cache write access to a shared scope — The workflow caches files (e.g., node_modules, pnpm store) using actions/cache with keys that overlap with the default branch.
  3. A privileged consumer — A release or deployment workflow that restores the poisoned cache and executes its contents.

Step 1: The Pwn Request

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') }}

Step 2: Cache Cross-Contamination

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.

Step 3: OIDC Token Extraction

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:

Case Study: The TanStack Attack Timeline

The TanStack postmortem provides a minute-by-minute account of the attack.

Pre-Attack Phase (Cache Poisoning)

Trigger Phase (OIDC Extraction)

Distribution Phase (npm Publishing)

Detection & Response

GitHub Actions Security Hardening Checklist

Critical — Do These First

Avoid pull_request_target for public repositories. Use pull_request instead.
Never cache untrusted files on the default branch. Scope cache writes to PR branches only.
Set GITHUB_TOKEN permissions to read-only by default. Use permissions: {} and elevate explicitly.

Secure Defaults

Pin third-party actions to full commit SHA. Never use @v4 tags — they can be rewritten.
Use OIDC for cloud access. Short-lived tokens, but pair with strict trust policies.
Separate read-only validation from privileged publishing. Use separate workflows.

FAQ

What is GitHub Actions cache poisoning?
GitHub Actions cache poisoning is a supply chain attack where an attacker injects malicious content into a workflow cache from a low-privileged workflow. When a privileged workflow restores that cache, the malicious payload executes with elevated permissions. CodeQL has a dedicated detection query.
How does the TanStack attack use cache poisoning?
The attacker poisoned the pnpm store cache via pull_request_target. When the release workflow restored that cache, attacker code extracted an OIDC token, exchanged it for AWS credentials, and published 84 malicious npm packages.
What is a Pwn Request attack?
A Pwn Request exploits pull_request_target which runs in the base repo context with full secret access. If the workflow executes code from the PR head, the attacker can steal tokens and secrets.
How can I detect cache poisoning?
Watch for unexpected cache hits from fork PRs, anomalous run times, and OIDC token usage from non-privileged workflows. Run CodeQL's cache-poisoning query and use StepSecurity for npm publishing anomaly detection.
How do I prevent cache poisoning?
Five mitigations: (1) Avoid pull_request_target with PR code execution. (2) Scope caches with unique keys. (3) Set GITHUB_TOKEN to read-only. (4) Pin actions to SHA. (5) Use OIDC with strict trust policies. See the Grafana breach analysis for more.
Can this affect my npm packages?
Yes. If your project publishes npm packages with misconfigured pull_request_target, you're vulnerable. See the Mini Shai-Hulud analysis for broader npm supply chain risks.

Need Help Securing Your CI/CD Pipeline?

I provide CI/CD security consultation as part of my web development services. Let's discuss your project.

Contact

Worried about your CI/CD security?

I provide security audits for GitHub Actions workflows. Free initial consultation.