Table of Contents What GitHub Actions cache poisoning actually is Why does it keep working What to audit in your repos today Audit 1: every pull_request_target workflow Audit 2: every workflow that interpolates untrusted input Audit 3: every workflow with id-token: write Audit 4: every third-party action pinned to a tag Audit 5: what your caches are keyed on Audit 6: your npm scope and publish rights What to change, in priority order 1. Remove every pull_request_target workflow that checks out PR code 2. Isolate or remove caches in release-capable workflows 3. Pin every third-party action to a commit SHA 4. Treat untrusted input as untrusted, especially around AI agents 5. Add zizmor or actionlint as a required PR check 6. CODEOWNERS on .github/ 7. Migrate to OIDC trusted publishing if you haven’t already 8. Enforce non-SMS 2FA on GitHub and npm 9. Set an install cooldown on your package manager 10. Treat AI agent config files as source code If you already got hit Closing References WHen I write an article, I try as much as possible to make it timeless. Thats why I avoid writing tips and tricks on AI because the ecosystem changes from month to month and then my article will become obsolete in a flash. I hope this article becomes obsolete and in the future nobody talks about this subject anymore. Because if you maintain a public repo with a publish pipeline on GitHub, there’s one class of attack you really need to know about. It’s called GitHub Actions Cache Poisoning, and it’s been hijacking open-source projects for 2 years now. It usually shows up as part of a chain with one or two other GitHub Actions weaknesses, but the cache is almost always the way in. Adnan Khan first demonstrated cache poisoning in 2024 against the Angular repo, as a research disclosure. In March 2025, a related supply-chain attack on tj-actions/changed-files pushed malicious code into 23,000+ downstream workflows using the same runner-memory-dump technique attackers would later reuse. In February 2026, cache poisoning compromised Cline ( I wrote about that one when it happened ), and 4,000 developers ended up with OpenClaw installed via the Cline CLI. And last week (or from what time period you are reading this), on May 11 2026, the full chain (untrusted PR → poisoned cache → memory dump) got TanStack: 84 malicious versions across 42 @tanstack/* packages, in six minutes. This article focuses on the mechanics: What it is, why it keeps working, and the checklist of things to audit and change in your own repo today. I’m not going to walk through the TanStack incident step by step; their team published a really good postmortem that you should read if you want the full picture. Let’s get started. What GitHub Actions cache poisoning actually is Most CI workflows spend a lot of their time installing dependencies: npm install , pnpm install , pip install , downloading Rust crates, and building native modules. On a fresh runner, all of that runs from scratch every time, even though the dependencies haven’t changed since yesterday. GitHub Actions has a feature that lets you skip that work. After your workflow installs everything, you can tell Actions to save the resulting folder somewhere (typically node_modules or the pnpm store) and name it whatever you like. The next workflow that runs and asks for the same name gets the folder back, ready to use, in a few seconds instead of a few minutes. That stored folder is the cache. The name you choose for it is the cache key. Every GitHub repo gets up to 10 GB of cache space to use however it likes. The key is something you build from the contents of the folder that identifies what’s in it. Most setups use the runner OS plus a hash of the lockfile, like this: Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')} If the lockfile changes, the hash changes, so you get a new key and a fresh install. If the lockfile doesn’t change, the key stays the same, and the cache is reused. But that cache pool is shared across the whole repo. Workflows on different branches, with different triggers and jobs, all read and write to the same pool. A PR build that ran yesterday can warm the cache for a release that runs today, because they compute the same key from the same lockfile. That’s by design; it’s what makes caching useful across a team. It’s also exactly what makes it dangerous. If an attacker can write to that cache pool, they can plant a poisoned dependency directory, a poisoned compiled binary, or a poisoned anything-else under a key that a later, higher-privileged workflow will look up. When that workflow restores the cache, the attacker’s code lands on the runner before any of the legitimate steps run. There are two main ways attackers get write access to the cache. The direct write. The attacker tricks a privileged workflow into running their code. Then they compute the cache key the release workflow will use and write a poisoned entry under that key. This is what hit TanStack (via a pull_request_target wo...
GitHub Actions Cache Poisoning is a supply-chain attack vector where malicious code submitted via a pull request can poison a repository's CI/CD cache, which is then executed in downstream workflows, often combined with other weaknesses like `pull_request_target`. The article provides a detailed audit checklist and prioritizes mitigations, including removing or isolating `pull_request_target` workflows that checkout PR code, pinning third-party actions to commit SHAs, and treating untrusted input as such. No specific CVE, CVSS score, or affected software versions are provided, as the article focuses on the attack pattern and defensive measures for any public repository using GitHub Actions.