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.

Left: which scopes a run can restore from. Right: the poisoning path, and the step GitHub closed by default on June 26, 2026.
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 |
Scope rules from the docs.github.com dependency caching reference, read 2026-11-12.
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.
```.github/workflows/triage.yml and nightly.yml
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` |
> Both posts read 2026-11-12. Cline's fix, per the Clinejection post, removed the AI triage workflows and stopped the nightly publish jobs consuming the Actions cache. Khan's 2024 conclusion was the same: avoid caching in release workflows.
## 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 |
> From the GitHub changelog entries of 2026-06-26 and 2026-09-10 and the docs.github.com workflow syntax for `cache-mode`, read 2026-11-12. The "Use it for" column is this page's recommendation, not GitHub's.
## 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: write` or `write-only` on `pull_request_target`, `issues`, `issue_comment` or `workflow_run`. Let one trusted `push` workflow keep the cache warm, and use `actions/cache/restore` in 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 into `run:` 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/cache` to a SHA like every other action, so the code doing the restore is the code you reviewed. [Pinning GitHub Actions to a commit SHA](https://latchkey.dev/learn/ci-security/pin-github-actions-to-commit-sha) covers how.
```.github/workflows/release.yml
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.
FAQ
What is cache poisoning in GitHub Actions?
It is writing a malicious entry into the Actions cache under a key a more trusted workflow will restore. When that workflow restores it, the attacker's files run in a job holding secrets the attacker could never reach directly.
Can a pull request from a fork poison the main branch cache?
Not through 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?
Because GitHub now issues a read-only cache token to low-trust triggers for the default branch scope, and the save is skipped with a warning while the job continues. Have a trusted push workflow save the cache, and switch the low-trust workflow to actions/cache/restore.
Does actions/cache verify the files it restores?
No. GitHub's dependency caching reference says cache contents are not signed or verified, and that extracted caches may modify files that are later executed. Treat a restored cache as input to the job, not as trusted build output.
Is it safe to use caching in a release workflow?
It is the riskiest place to use it, because the release job holds the credentials an attacker wants. Both published cases ended at a release token, and both researchers recommend keeping caches out of release workflows; cache-mode: none on the job does that.
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)
Top comments (0)