DevOps

CI/CD Cache Poisoning in 2026: Practical Defenses for GitHub Actions and GitLab Runners

Build caches are one of those things nobody talks about until they become the reason a bad artifact shipped to production. Over the last year, more teams started tightening identity in CI with OIDC and short-lived cloud credentials, but many pipelines still trust shared caches too much. That gap matters. If an attacker can influence cached dependencies, compiled layers, or test artifacts, they can often bypass the controls you thought were protecting your release path.

This is showing up repeatedly in community threads across DevOps and AppSec circles: teams lock down secrets, then leave cache keys broad enough that untrusted branches can read or write entries used by protected builds. The result is classic cache poisoning with modern tooling.

Where cache poisoning actually happens

The weak points are usually predictable:

  • Branch collisions: cache keys based only on dependency files, not trust level (PR vs protected branch).
  • Cross-project runner reuse: shared self-hosted runners retaining workspace state between jobs.
  • Restore keys that are too permissive: fallback logic pulling “close enough” caches from unrelated contexts.
  • Mutable base layers: pipeline reuses stale image layers without validating digest provenance.

None of this is theoretical. Even when the attacker only controls a fork PR, they may still be able to populate a cache entry later consumed by a privileged release workflow.

Defensive pattern #1: split trust zones in cache keys

Do not let untrusted and trusted workflows share the same keyspace. Include workflow trust in the key itself.

# GitHub Actions example
- name: Compute trust scope
  run: |
    if [[ "${{ github.event_name }}" == "pull_request" ]]; then
      echo "CACHE_SCOPE=untrusted" >> $GITHUB_ENV
    else
      echo "CACHE_SCOPE=trusted" >> $GITHUB_ENV
    fi

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ env.CACHE_SCOPE }}-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ env.CACHE_SCOPE }}-${{ runner.os }}-

The important detail is not the exact variable name; it is the hard separation of trust domains. A trusted workflow should never restore from an untrusted scope, even if it means a few slower builds.

Defensive pattern #2: pin and verify base artifacts

If you build containers, cache poisoning often rides in through mutable tags. Pin digests and verify signatures before reuse.

# Example guard step
IMAGE="ghcr.io/org/base@sha256:..."
cosign verify --certificate-identity-regexp 'https://github.com/org/.+' "$IMAGE"
docker pull "$IMAGE"

Trade-off: this adds a bit of friction when base images are updated frequently. In practice, the operational cost is small compared to post-incident cleanup after a tainted layer propagates.

Defensive pattern #3: ephemeral runners for privileged workflows

If your release job can publish packages, push production images, or apply Terraform, run it on ephemeral infrastructure. Shared long-lived runners are convenient, but they widen blast radius. For GitLab Runner and self-hosted GitHub runners, prefer autoscaled VMs/containers that are destroyed after each job.

If full ephemerality is not feasible yet, enforce a cleanup baseline:

  • Delete working directories after every job.
  • Clear language package caches for privileged pipelines.
  • Deny Docker socket access unless the job explicitly requires it.
  • Disable reuse of workspaces across projects.

Defensive pattern #4: separate “build” and “release” identities

A common mistake is giving one workflow both build and publish rights. Keep them separate. Let build workflows produce artifacts, then require a second trusted workflow to attest and publish.

Minimal model:

  1. Build job runs on broader trigger set, no publish credentials.
  2. Build outputs signed provenance (SLSA/in-toto or equivalent attestation).
  3. Release job runs only on protected branch/tag, validates attestation, then publishes.

This does not eliminate cache risk, but it dramatically reduces the chance that a poisoned intermediate object reaches your registries unchallenged.

Quick audit checklist you can run this week

  • List every cache key in your pipelines and mark whether untrusted code can write to it.
  • Review restore-key fallbacks; remove any that cross trust boundaries.
  • Confirm privileged workflows run on isolated runners.
  • Check whether release steps verify artifact provenance before publish.
  • Measure cache hit-rate impact after tightening keys (so teams see the real cost, not assumed cost).

Most teams find the performance hit is smaller than expected once keys are scoped correctly.

When this guidance does not fully apply

Monorepos with extremely expensive builds sometimes need broader cache sharing to stay productive. In that case, don’t revert to blind trust. Use a middle path: signed cache manifests, aggressive TTLs, and branch protection rules that restrict who can trigger cache-writing workflows. You can also isolate by environment (dev/stage/prod) even if you cannot isolate every branch.

The bigger point: treat caches as part of your software supply chain, not a speed-only optimization. In 2026, attackers are looking for build shortcuts because that’s where mature teams still have uneven controls.