
GitHub made cache-mode generally available on September 10. It is one YAML field — set at the workflow or job level — that closes the pull_request_target cache poisoning vector that burned TanStack in May. If you run any workflow against fork pull requests, here is what changed and what to set today.
Why the Cache Was Always the Soft Underbelly
GitHub Actions caches are scoped to the repository, not to individual workflows. That sounds fine until you remember that pull_request_target executes in the base repo’s context — with elevated permissions and secrets — while running code from a fork. The workflow permissions model controls what a job can do to secrets and the GitHub API. It has never controlled what a job can do to the shared cache.
The consequence: even a workflow with permissions: {} can write to the cache through actions/cache’s post-run step. The cache key is typically derived from a lockfile hash — the same hash a release pipeline will use. An attacker who poisons that entry under a trusted key does not need to touch your secrets directly. They wait for the release job to restore it.
That is exactly the TanStack attack from May 2026. Attacker opens a fork PR to TanStack/router. The bundle-size.yml workflow fires via pull_request_target, runs the fork’s code, and writes a poisoned pnpm store to the cache. When the release pipeline runs later that day, it restores the same cache entry. The attacker’s payload executes with contents: write and id-token: write permissions, exfiltrates a GitHub OAuth token, and publishes 84 malicious packages across 42 TanStack packages. The detailed post-mortem on the TanStack attack is worth reading in full — it is a clean case study in how trust boundary splitting through workflow permissions alone is not sufficient when the cache operates outside that model.
What cache-mode Does
GitHub made cache-mode generally available on September 10. There are four values:
read— restore caches, deny saves. Default for low-trust events:pull_request_target,workflow_run, fork pull requests.write— restore and save. Default for trusted events:push, same-repo pull requests.write-only— save only, cannot restore. Useful for dedicated cache warm-up jobs.none— no cache access. Full isolation for release pipelines where a cold start is the safest choice.
You can set it at the workflow level (applying to all jobs) or at the job level (overriding the workflow default for that job). Job-level always wins. Reusable workflows cannot escalate: a called workflow cannot receive more cache access than its caller granted. The effective mode is exposed in the ACTIONS_CACHE_MODE environment variable and honored automatically by actions/cache and the @actions/cache toolkit. A skipped restore is treated as a cache miss — the workflow continues. A skipped save is silently not performed.
One important note: if you explicitly set cache-mode: write on a low-trust event like pull_request_target, GitHub Actions now adds a warning annotation. That is the correct behavior. That configuration is almost never the right choice.
Who Needs to Act and What to Set
The honest answer is that most teams are already protected for the common case. GitHub quietly issued read-only cache tokens to fork pull_request events in June 2026 — that change silently fixed the simplest attack path. cache-mode is explicit control for the rest.
You need to add cache-mode: read (at minimum) if you have:
- Any workflow triggered by
on: pull_request_targetthat usesactions/cache, or any setup action (actions/setup-node,actions/setup-python,actions/setup-go) — these all cache internally. - Any workflow triggered by
on: workflow_runthat touches the cache.
Here are the three patterns you will actually reach for:
Pattern 1: Lock down a fork PR workflow
on: pull_request_target
cache-mode: read
jobs:
bundle-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v5
- run: npm ci
Pattern 2: Mixed modes in one workflow
jobs:
build:
runs-on: ubuntu-latest
cache-mode: write # trusted push populates cache
test-fork:
runs-on: ubuntu-latest
cache-mode: read # fork-triggered job can restore but not poison
Pattern 3: Full isolation for a release pipeline
jobs:
release:
runs-on: ubuntu-latest
cache-mode: none # cold start — no cache read or write
The full cache-mode syntax reference is on GitHub Docs. The GitHub Security Lab patterns and mitigations guide has deeper coverage of the pull_request_target risk landscape if you want to audit beyond caching.
The Larger Picture
This is layer two of GitHub’s three-part 2026 CI security roadmap. Layer one was actions/checkout v7 (June 2026), which refuses to check out fork code in pull_request_target workflows without explicit opt-in. Layer two is cache-mode: decoupling cache write access from workflow trust level. Layer three — an egress firewall for hosted runners — is six to nine months out.
The pattern here is that GitHub is systematically closing the attack surfaces the TanStack, tj-actions, and Trivy incidents exposed. Each layer is independent and additive. Using all three is the right posture. For now, cache-mode: read on your pull_request_target workflows is a five-minute fix that closes a supply chain attack vector that has burned projects with millions of weekly downloads. Run the audit, add the field, move on.













