Fixing cache poisoning between branches Jump to heading

Build caches are usually reasoned about as a performance feature, which hides what they are: mutable storage, written by one pipeline run and read by another, frequently across a trust boundary. If a run triggered by an untrusted contribution can write an entry that a run on the default branch later restores, that contributor can place files into a trusted build. Nothing in the build output announces it. This recipe explains the shape of the problem, the scoping that prevents it, and how to clean up if it has already happened β€” part of CI caching and runner performance.

When to use this approach Jump to heading

  • Your repository accepts pull requests from forks and uses a build cache.
  • Cache entries are keyed or restored with broad prefixes.
  • Any cached path contains executable content β€” binaries, scripts, compiled modules.
  • A security review has asked how build inputs are trusted.
  • If the repository is private with a small trusted team, the risk is lower, but the scoping below still prevents accidental cross-branch corruption.

Step 1 β€” Map who can write what Jump to heading

The question is not what the cache contains but which contexts can put things into it.

# Every cache entry, with the ref that created it
gh cache list --limit 100 --json key,ref,createdAt,sizeInBytes \
  --jq '.[] | {key, ref, mb: (.sizeInBytes / 1048576 | floor)}' | head -20
# Entries written from anything other than the default branch
gh cache list --limit 100 --json key,ref \
  --jq '.[] | select(.ref != "refs/heads/main") | {key, ref}'
How a cache entry crosses a trust boundaryA pull request run from a fork executes contributor code and writes a cache entry. A later run on the default branch restores that entry using a shared prefix, and whatever the contributor placed in the cached path becomes part of a trusted build.Fork pull requestruns contributor codeCache writeshared key prefixDefault branch runrestore by prefixTrusted buildcontains foreign contentno step here is a bug in isolation β€” the boundary is crossed by the key, not by the code

Step 2 β€” Scope writes to trusted refs only Jump to heading

The rule is simple: less trusted contexts may read the cache and must not write it.

# .github/workflows/ci.yml
      - uses: actions/cache/restore@v4              # read on every run
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

      - uses: actions/cache/save@v4                 # write only from the default branch
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Verification: no entry should carry a fork ref after this change
gh cache list --limit 50 --json key,ref --jq '.[] | select(.ref | test("pull/")) | .key'

Splitting restore and save is the whole mechanism. The combined action does both, which is convenient and means every run that reads also writes.

Step 3 β€” Never cache executable output across the boundary Jump to heading

Caching a dependency download store is comparatively safe: entries are content-addressed and verified against the lockfile on install. Caching compiled output is not, because nothing re-verifies it.

What is safe to share across a trust boundaryA package store is content-addressed and checked against the lockfile during installation, so a tampered entry fails rather than executing. Compiled output and tool binaries are consumed directly, with nothing to verify them against.Compiled outputPackage storeverified on usenothing checks ithashes in the lockfileexecuted directlyyesnosafe to share widelynoyesrebuild cost if droppedminutessecondsif the consumer does not verify it, the cache must not cross the boundary
# Audit what your cached paths actually contain
find ~/.npm -type f -perm -u+x | head       # expect nothing executable
find ./build-cache -type f -perm -u+x | wc -l

SAFETY WARNING β€” a cached toolchain, compiler output or node binary restored into a trusted build is executed without verification. Treat any such cache as a deployment artefact: write it only from the default branch, key it on the full set of inputs, and prefer rebuilding over restoring when the entry’s provenance is uncertain. The equivalent discipline for release artefacts is in verifying attestations before deploy.

Step 4 β€” Detect an entry that should not exist Jump to heading

# Entries whose key does not match the current lockfile hash
current="npm-Linux-$(sha256sum package-lock.json | cut -c1-64)"
gh cache list --limit 100 --json key --jq '.[].key' \
  | grep '^npm-Linux-' | grep -v "$current" | head
# Unexpected size is the clearest signal: a store should not double overnight
gh cache list --limit 100 --json key,sizeInBytes,createdAt \
  --jq 'sort_by(-.sizeInBytes)[0:5] | .[] | {key, mb: (.sizeInBytes/1048576|floor), createdAt}'

An entry substantially larger than its siblings, or one created from a ref that is not the default branch, is worth deleting on suspicion. Deleting a cache entry costs one cold build.

Step 5 β€” Clean up and re-key after an incident Jump to heading

If you believe an entry was poisoned, deleting it is not enough on its own: anything built from it may have been published.

# 1. Delete every entry in the affected family
gh cache delete --all 2>/dev/null || \
  for k in $(gh cache list --limit 100 --json key --jq '.[].key'); do gh cache delete "$k"; done

# 2. Change the key prefix so nothing old can be restored by a stale workflow
#    npm-v2-${{ runner.os }}-${{ hashFiles(...) }}

# 3. Rebuild and republish anything released from a build in the affected window
git log --since='2026-09-01' --until='2026-09-18' --oneline --first-parent main
# Verification: the cache is empty and the new prefix is in use
gh cache list --limit 10 --json key --jq '.[].key'
The order of a cache-poisoning responseStop the writes first so nothing new can be planted, then delete the entries, then change the key prefix so a stale workflow cannot restore an old one, and finally rebuild anything released from the affected window.Stop writesrestrict to the default branchstep 1Delete entrieswhole familystep 2Re-keynew prefixstep 3Rebuildanything released sincestep 4deleting without re-keying leaves a stale workflow able to repopulate the old key

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is this a real attack or a theoretical one? Jump to heading

It has been demonstrated repeatedly against public repositories, and it is attractive precisely because the payload lands in a build rather than in a diff. Nobody reviews a cache entry, and the resulting artefact looks like every other one. The mitigations are cheap, which is the main argument for applying them regardless of your threat assessment.

Does restricting writes hurt the hit rate? Jump to heading

A little on the first run of a new lockfile, and almost not at all afterwards, because the default branch warms the cache for everyone. The entries that matter are the ones for the lockfile in the default branch, and those are exactly the ones still being written.

What about self-hosted runners with a shared local cache? Jump to heading

The same reasoning applies with more force, because a local directory has no scoping at all. Either run untrusted contributions on ephemeral runners with no access to the shared cache, or do not run them on self-hosted infrastructure β€” which is the usual recommendation.