Skip to content
Latchkey LogoLatchkey home

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.

Cache scope tree with main at the top, beside the three steps from a low-trust run to a release
Left: which scopes a run can restore from. Right: the poisoning path, and the step GitHub closed by default on June 26, 2026.

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 onCan restore fromSaves to
main, from push or schedulemainmain
feature-b, based on feature-afeature-b, feature-a, mainfeature-b
A pull_request into mainIts merge ref, mainrefs/pull/<n>/merge
pull_request_target, issues, issue_comment, workflow_runmainNothing 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.

.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.

CaseEntry pointPivotWhat was reachable
Angular, May 6, 2024 ("The Monsters in Your Build Cache")Code execution in a workflow running in the default branch contextEntries written with the job's cache credentials; the post introduced forcing eviction by filling the cacheProduction 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 accountCacheract filled the cache past 10 GB, then wrote entries matching the nightly workflow's keysVSCE_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-modeRestoreSaveUse it for
readYesNoLow-trust triggers (their default) and pull request checks
writeYesYesThe trusted push or schedule workflow that keeps the cache warm
write-onlyNoYesA job that only primes the cache
noneNoNoRelease 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: 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 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.

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: none on release jobs and never grant write to a low-trust trigger.

Frequently asked questions

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

Cache entries are yours to lock down. Latchkey makes sure nothing else on disk outlives the job. Start free → 30-day trial · No credit card