Securing and Invalidating Build Caches

The operational pain this page solves is the two-sided failure of build caches: a cache that is too sticky serves stale or wrong artifacts and produces “works on my machine, breaks in CI” mysteries, while a cache that untrusted jobs can write becomes an attack surface where a poisoned entry compromises every downstream build. Getting build caching right means keys that hit exactly when they should, isolation that stops poisoning, and invalidation you control. This page covers all three for dependency, build, and remote caches.


Prerequisites


How Cache Correctness and Isolation Work Under the Hood

A build cache is a map from inputs to outputs. Two independent things can go wrong: the key can be wrong (correctness) or the wrong party can write an entry (security).

Correctness — the key must capture every input. If output depends on an input the key ignores, the cache returns a stale artifact when that input changes. A complete key hashes: the lockfile (dependencies), the tool and runtime versions (a Node 18 vs 20 build differs), the build config (vite.config.ts, tsconfig.json), and, for content-addressed build caches, the source files themselves. Conversely, including volatile noise (a timestamp, $RANDOM) means the key never hits. The art is hashing exactly the inputs, no more and no less — the same discipline detailed in cache-key strategies for deterministic CI builds.

Isolation — writes must be trusted. A shared cache is a shared trust boundary. If a fork PR’s job can write to the cache that trusted main builds read, an attacker forks the repo, opens a PR that writes a malicious artifact under a key main will later restore, and every subsequent build is compromised. This is cache poisoning. The fix is scope: fork/untrusted builds get read-only or fully isolated caches; only trusted branches write to the shared scope.

Cache Key Composition and Write Isolation Four inputs — lockfile, tool version, config, and source — are hashed into a cache key. Trusted-branch builds have read and write access to the shared cache scope; fork or untrusted builds have read-only access, which prevents cache poisoning. lockfile tool version config source hash Hash → key deterministic Scoped Cache shared entry Trusted branch read + write Fork / untrusted read-only — no write

Step-by-Step Implementation

Step 1 — Compose a complete, deterministic cache key

# GitHub Actions — key hashes every output-affecting input
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      .turbo
    # Bump CACHE_EPOCH to force a global rebuild without touching inputs.
    key: v3-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml', 'turbo.json', 'tsconfig.json') }}
    restore-keys: |
      v3-${{ runner.os }}-node20-

Verification: Change a dependency in pnpm-lock.yaml and confirm the run reports a cache miss; re-run with no changes and confirm a hit.

Step 2 — Isolate cache writes by trust level

# Only trusted branches write the shared cache; fork PRs read a restore-only copy.
- uses: actions/cache@v4
  if: github.event.pull_request.head.repo.full_name == github.repository   # not a fork
  with:
    path: node_modules
    key: v3-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- uses: actions/cache/restore@v4    # fork PRs: RESTORE only, never save
  if: github.event.pull_request.head.repo.full_name != github.repository
  with:
    path: node_modules
    key: v3-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

Verification: Open a PR from a fork and confirm the job restores but does not save the cache (no “Cache saved” line in the log).

Step 3 — Restrict and verify remote cache writes

# Turborepo remote cache: trusted CI uses a read-write token; local/fork uses read-only.
# In trusted CI:
export TURBO_TOKEN="$TURBO_RW_TOKEN"
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="$TURBO_SIG_KEY"   # sign entries
pnpm turbo build --remote-only

# For untrusted contexts, provide a read-only token and disable writes:
export TURBO_TOKEN="$TURBO_RO_TOKEN"
pnpm turbo build --remote-cache-read-only

Verification: With signing enabled, a tampered remote artifact fails signature verification on restore and Turborepo rebuilds rather than using it.

Step 4 — Invalidate deliberately

# A CACHE_EPOCH repo variable in the key prefix (v3- above) lets you force a clean
# rebuild for reasons the hashed inputs cannot see — base-image bump, native dep, or
# a suspected corrupt entry — by bumping one value instead of editing every workflow.
env:
  CACHE_EPOCH: v3

Verification: Bump the epoch prefix and confirm the very next run misses every cache and rebuilds cleanly.


Cache Scope Against Blast Radius A globally shared cache gives the highest hit rate and the widest blast radius: a poisoned entry reaches every build. A branch-scoped cache limits both. A per-run cache has no reuse and no shared risk. Read-only access for untrusted runs keeps the hit rate of the global scope without its write risk. SCOPE · HIT RATE · BLAST RADIUS OF A POISONED ENTRY global — highest hit rate, every future build affected per branch — good hit rate, one branch affected per run — no reuse the answer global scope, but read-only for untrusted runs top row's hit rate, bottom row's risk Scope is a lever between speed and risk only until you separate read from write — after that, you can have both.

Configuration Reference

Option Type Default Effect
key inputs hashed files Must include lockfile, tool version, config; omissions cause stale hits
restore-keys prefix list none Fallback partial matches; keep the prefix strict enough to avoid cross-config reuse
Cache epoch/version prefix string Manual global invalidation lever
Fork write access boolean denied Fork PRs must be restore-only to prevent poisoning
Remote signature key secret none Signs/verifies remote entries against tampering
Remote token scope enum read-write Untrusted contexts get read-only tokens

Integration with Upstream and Downstream Topics

Cache security and invalidation underpin every build-caching technique:


Performance and Cost Impact

Scenario Effect Note
Complete key, warm cache 60–95% build-time reduction The payoff of correct caching
Incomplete key (stale hits) intermittent wrong builds Debugging cost dwarfs any speed gain
Over-specified key (noise) ~0% hit rate Cache never helps; pure overhead
Fork write allowed poisoning risk One incident can invalidate weeks of trust
Signed remote entries +tiny verify cost Negligible vs the integrity guarantee

Correctness pays for itself twice: it delivers the speed of caching and avoids the far larger cost of debugging a stale-artifact incident.


Why Cache Poisoning Is Found Late A poisoned entry is written from an untrusted pull-request run at hour zero. It is restored by trusted builds for the next several days without any error. The first symptom appears when someone notices unexpected behaviour in production, by which time dozens of builds have consumed the entry. poisoned write hour 0 restored by 40+ trusted builds — no error, no warning, no signal first symptom day 4, in production THE INTERVAL IS THE PROBLEM A cache hit is indistinguishable from a correct build, so nothing detects the poisoning until behaviour diverges. Prevention is the only viable control here: by the time detection is possible, the entry is everywhere.

Troubleshooting

Symptom: CI builds succeed but ship stale output

Cause: The cache key omits an input that changed — commonly the tool version or a build config file not in hashFiles.

Fix: Add every output-affecting file to the key (Step 1). If unsure which input drifted, bump the cache epoch to force a clean rebuild and compare.

Symptom: Cache hit rate is near zero

Cause: The key includes volatile content — a timestamp, commit SHA, or $RANDOM — so no two runs share a key.

Fix: Remove volatile components; key only on stable, output-determining inputs. Use restore-keys for partial fallback, not volatility.

Symptom: A malicious or corrupt artifact appears in trusted builds

Cause: An untrusted job wrote to the shared cache scope — classic poisoning via a fork PR.

Fix: Make fork builds restore-only (Step 2), enable remote signature verification (Step 3), and rotate the cache epoch to purge suspect entries. Follow the poisoning remediation runbook.

Symptom: A dependency update is not picked up

Cause: The lockfile changed but the key hashes only package.json, or restore-keys matched an old entry too eagerly.

Fix: Hash the lockfile, not the manifest, and tighten restore-keys so the prefix cannot match across incompatible dependency sets.


Frequently Asked Questions

What makes a cache key correct?

A correct key hashes every input that can change the output and nothing that cannot. For a frontend build that means the lockfile, the Node and package-manager versions, the build config (vite.config.ts, tsconfig.json, turbo.json), and — for content-addressed build caches — the source files. Identical inputs then produce identical keys (a hit), and any meaningful change produces a new key (a miss). Omitting an input causes stale hits; including volatile noise causes a permanent miss.

How does cache poisoning happen and how do I prevent it?

Poisoning happens when a job you do not fully trust — typically a pull request from a fork — can write to a cache that trusted pipelines later read. An attacker writes a malicious artifact under a key main will restore, and every subsequent trusted build inherits it. Prevent it by scoping write access: fork and untrusted builds get restore-only or fully isolated caches, only protected branches write the shared scope, and remote entries are signed so tampering is detected on restore.

When should I manually invalidate a cache?

When an input that your key does not (and perhaps cannot) capture changes: a base container image updated upstream, a native/system dependency, a compiler flag set outside the hashed files, or a cache entry you suspect is corrupt. A cache epoch or version prefix in the key (the v3- above) lets you bump one value to force a global clean rebuild, instead of hunting through every workflow to change keys individually.


Designing the Invalidation Story Before You Need It

Every cache eventually holds something wrong, and the difference between a ten-minute fix and a day of confusion is whether an invalidation path was designed in advance. Three mechanisms cover the realistic cases, and they are worth having all three.

Key versioning. A literal version component in every cache key — v3-npm-linux-… — is the blunt instrument that always works. Bumping it in a one-line commit orphans every existing entry instantly, without needing to know which entries were affected or having any delete permission on the cache backend. It costs exactly one cold build. Include it from the first day: adding it later means the first incident is handled without it.

Scoped purging. Where the backend supports deletion, being able to remove a prefix — one branch, one package, one task type — is the middle option, useful when only part of the cache is suspect and a full cold rebuild would be expensive. Its weakness is that it depends on knowing the blast radius, and in a poisoning incident you usually do not.

Time-based expiry. Lifecycle rules that expire entries after thirty days are not an incident tool, but they bound the age of anything that can be restored and keep storage from growing without limit. They also quietly limit the damage of a poisoning you never detected, which is a real benefit given how long such an entry can otherwise survive.

What “invalidate” cannot fix

It is worth being clear about the limit. Invalidation removes the ability to restore a bad entry; it does nothing about artifacts already built from one and already deployed. If a poisoned cache entry contributed to a released build, purging the cache is the second step — the first is identifying which releases consumed it, and that requires the build to record which cache entries it restored. Emitting the restored cache keys into the build log, or better into the provenance attestation, converts that question from an investigation into a query.

That recording step is cheap and almost universally skipped. A single log line per restore, retained as long as the artifact it contributed to, is the difference between “we purged the cache and believe we are fine” and “these four releases restored the affected entry and none of them are in production”.

Deciding what is worth caching at all

Not everything benefits from a cache, and a cache that rarely hits is pure risk with no return. Two properties make a task a good candidate: its inputs must be stable relative to how often it runs, and its output must be expensive relative to the cost of storing and transferring it. A dependency install scores well on both. A lint run over the whole repository scores badly on the first, because its input set includes every file. A trivial code-generation step scores badly on the second, because storing and fetching the output costs more than regenerating it.

Measuring hit rate per task, rather than for the cache as a whole, is what makes those judgements concrete. A task below about thirty per cent is usually better removed from the cache entirely: it contributes a full share of the correctness risk while returning almost none of the speed.

Recording What Each Build Restored

The question that matters during a cache incident is which releases consumed the suspect entry, and it is unanswerable unless the build recorded what it restored. A single log line per cache restore — the key, and whether it was an exact or a fallback hit — costs nothing and turns an investigation into a query.

Better still, carry the restored keys into the build’s provenance attestation, where they are retained for as long as the artifact they contributed to. Then “did release 4.12 restore the affected entry” is a lookup rather than an archaeology exercise across expired logs, and the blast radius of a poisoning incident can be established in minutes rather than assumed.

One habit that prevents most of this

Write the cache key’s input list into a comment beside the key itself, in the workflow file. It takes a line, it makes the reviewer’s job possible, and it is the artefact that turns “is this key complete” from a question requiring archaeology into one answerable by reading. Most unsound keys are unsound because nobody could see, at review time, what the task actually read.

← Back to Build Optimization & Caching Strategies