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.


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.


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.


← Back to Build Optimization & Caching Strategies