How a poisoned GitHub Actions cache entry reaches your release job
GitHub Actions cache poisoning is when code running in a low-trust workflow saves a cache entry under a key that a more privileged workflow later restores, so the attacker's files run in a job that holds release secrets. It works because every branch can read the default branch's cache and cache contents are not verified; since June 26, 2026 low-trust triggers get a read-only cache token by default, and cache-mode sets access per job.
The cache is the one place in GitHub Actions where state crosses from one workflow to another by design. A run on main saves node_modules, and tonight's release workflow restores it instead of installing again. If someone other than you got to write that entry, the release ran their files.
What follows: how the cache is scoped, how a privileged workflow ends up restoring a poisoned entry, the Angular and Cline write-ups that showed it, what GitHub changed in 2026, and the settings to apply now.

How the Actions cache is scoped
The cache boundary is the branch. A run can restore caches created on its own branch or on the default branch, and a pull request run can also restore from its base branch, including the base branch of a fork. It cannot restore from a child or a sibling: a cache saved on feature-a is invisible to feature-c, and nothing saved on a feature branch is visible to main. Tags are separate scopes too.
Pull requests write to their own corner. A cache created by a pull_request run is saved for the merge ref, refs/pull/<n>/merge, and only re-runs of that pull request can restore it, so a fork pull request cannot write to main. What can is any run whose context is the default branch, and several triggers run there even when a stranger starts them.
| A run on | Can restore from | Saves to |
|---|---|---|
main, from push or schedule | main | main |
feature-b, based on feature-a | feature-b, feature-a, main | feature-b |
A pull_request into main | Its merge ref, main | refs/pull/<n>/merge |
pull_request_target, issues, issue_comment, workflow_run | main | Nothing by default since June 26, 2026; main before that |
How a poisoned entry gets restored
Three properties line up. The key is chosen by the client: Adnan Khan's 2024 research found the server enforces the branch boundary and nothing else, so code holding the job's cache token can save an entry under any key it likes, including the one the release workflow will ask for. The contents are not checked: GitHub's reference says cache contents are not signed or verified, and that extracted caches may modify files that are later executed. And entries are evicted oldest first once a repository passes its storage limit, 10 GB by default, so junk can push the real entry out and make room for the attacker's.
Then the entry waits. Nothing happens until a privileged workflow, a push to main or a nightly release, restores that key and runs a step that reads the restored files: an install script in node_modules, a toolchain binary, a package.json overwritten on extraction. That step runs with the release job's secrets in scope. The pair below is the shape the Cline case had: same key, same scope, one workflow open to anyone.
# triage.yml: anyone with a GitHub account can start it by opening an issue
on:
issues:
types: [opened]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# ...a step that the issue text can steer
# nightly.yml: the same key in the same default-branch scope, plus a publish token
on:
schedule:
- cron: '0 3 * * *'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- run: npm run build && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}Angular and Cline, end to end
Both write-ups are by Adnan Khan, and both end at a release credential. Neither needed access to the release workflow itself: only to a workflow that shared its cache scope.
| Case | Entry point | Pivot | What was reachable |
|---|---|---|---|
| Angular, May 6, 2024 ("The Monsters in Your Build Cache") | Code execution in a workflow running in the default branch context | Entries written with the job's cache credentials; the post introduced forcing eviction by filling the cache | Production deployment secrets for angular.dev and angular.io |
| Cline, February 9, 2026 ("Clinejection") | An issue title passed into an AI triage workflow on the issues event, open to any GitHub account | Cacheract filled the cache past 10 GB, then wrote entries matching the nightly workflow's keys | VSCE_PAT, OVSX_PAT and NPM_RELEASE_TOKEN |
What GitHub changed in 2026
On June 26, 2026 GitHub started issuing read-only cache tokens for the default branch's scope when the triggering event can be started by someone without write access. Only push, schedule, workflow_dispatch, repository_dispatch, delete, registry_package and page_build keep write access there; runs from pull_request_target, issue_comment and other low-trust events can restore but not save. A blocked save logs a warning and the job carries on. Under that default, the Clinejection triage workflow could still have restored from main, but not written to it.
On September 10, 2026 cache-mode became generally available on all plans. It sets cache access per workflow or per job, a job value overrides the workflow value, and the cache service enforces it. An explicit value on a calling job also caps what a reusable workflow it calls can request; without one, the called workflow can ask for write even under a low-trust trigger, so set cache-mode: read on jobs that call workflows you do not own. Declaring write on a low-trust trigger turns the protection back off, and GitHub adds a warning annotation when you do.
cache-mode | Restore | Save | Use it for |
|---|---|---|---|
read | Yes | No | Low-trust triggers (their default) and pull request checks |
write | Yes | Yes | The trusted push or schedule workflow that keeps the cache warm |
write-only | No | Yes | A job that only primes the cache |
none | No | No | Release and publish jobs that hold credentials |
Mitigations to apply now
The default closes the most common path. These close the rest, in order of what they buy.
- Give release and publish jobs
cache-mode: none, or take caching out of them. Both write-ups end at a release secret, and a job that restores nothing cannot restore a poisoned entry. The cost is one cold install per release. - Never declare
cache-mode: writeorwrite-onlyonpull_request_target,issues,issue_commentorworkflow_run. Let one trustedpushworkflow keep the cache warm, and useactions/cache/restorein low-trust workflows so the intent is visible. - Do not run untrusted code in a default-branch context at all: no checkout of a pull request head under
pull_request_target, and no${{ }}interpolation of issue or comment text intorun:blocks or agent prompts. - Keep secrets out of cached paths. Anyone who can open a pull request can read the base branch's caches.
- Pin
actions/cacheto a SHA like every other action, so the code doing the restore is the code you reviewed. Pinning GitHub Actions to a commit SHA covers how.
cache-mode: read # every job in this file: restore only
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('**/package-lock.json') }}
- run: npm ci && npm test
publish:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
cache-mode: none # holds the publish token, so it restores nothing
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: npm ci && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}Where a fresh runner helps, and where it does not
The Actions cache is a service, not a disk. A runner that is destroyed after every job stops one job's leftovers on the machine from reaching the next job, which is the attack on self-hosted runners on public repositories. It does nothing about a poisoned entry in the cache service, because restoring entries from earlier runs is what a cache is for.
So the defenses for this attack are the ones above, on any runner: who may write to the default branch's scope, which jobs restore from it, and what those jobs hold when they do. For how keys, restore keys and scopes decide a hit in normal use, see GitHub Actions caching explained.
Key takeaways
- Every run can restore from the default branch, so whoever can write there feeds every privileged workflow that uses the cache.
- Keys are chosen by the client and contents are not verified; eviction past 10 GB lets an attacker replace a real entry.
- Since June 26, 2026, low-trust triggers get a read-only cache token for the default branch by default.
- Set
cache-mode: noneon release jobs and never grantwriteto a low-trust trigger.
Frequently asked questions
What is cache poisoning in GitHub Actions?
Can a pull request from a fork poison the main branch cache?
pull_request: its caches are saved for the merge ref and only re-runs of that pull request can restore them. The risk is triggers that run in the default branch context, such as pull_request_target, issue_comment or issues, and since June 26, 2026 those get read-only access to that scope by default.Why is actions/cache not saving on pull_request_target or issue_comment?
push workflow save the cache, and switch the low-trust workflow to actions/cache/restore.Does actions/cache verify the files it restores?
Is it safe to use caching in a release workflow?
cache-mode: none on the job does that.Related guides
References
- Dependency caching reference: access restrictions, low-trust triggers, cache-mode (read 2026-11-12)
- GitHub changelog: Read-only Actions cache for untrusted triggers (2026-06-26)
- GitHub changelog: Control GitHub Actions cache access with cache-mode (2026-09-10)
- Adnan Khan: The Monsters in Your Build Cache, GitHub Actions cache poisoning (2024-05-06)
- Adnan Khan: Clinejection (2026-02-09)
- GitHub Actions documentation