Skip to content
SRE & DevOpsDeep Dive Published Updated 7 min readViews unavailable

GitHub Actions Cache Security: Keys, Trust Boundaries, and Poisoning

Design Actions caches as untrusted build inputs, separate fork and release trust, avoid secret leakage, and validate restore-key behavior before promotion.

GitHub Actions caches are performance hints, not authoritative build artifacts. A workflow can restore files created by an earlier run and feed them into a later build. That makes cache design part of the build’s trust model: a malicious or corrupted entry can influence a trusted job if the job consumes restored files as executable code, compiler state, generated metadata, or package-manager content.

Cache access depends on the event and ref. Pull-request workflows, including those from forks, can restore default-branch caches, but caches they create are scoped to the pull-request merge ref and cannot overwrite the default branch’s cache. Other low-trust triggers that resolve to the default branch, such as pull_request_target, issue_comment, and workflow_run, receive read-only access to the default-branch cache scope by default. A workflow or job can override that restriction with a write-capable cache-mode, which reintroduces cache-poisoning risk. These protections limit where entries can be written; they do not make cache data trustworthy.

Know what is shared

GitHub identifies a cache using its key, cache version, and branch scope. The cache version accounts for details such as the paths being archived and compression implementation, so identical textual keys can still refer to different cache versions. A cache created on the default branch can be restored by other branches; pull-request merge refs can have their own scopes and access rules. When a cache is restored, the files are delivered as stored rather than rebuilt from source.

This model has useful consequences. A cache key should describe inputs that affect the cached data, such as operating system, runtime, lockfile hash, compiler version, and relevant build flags. A stale cache can be correct if the package manager validates its contents and downloads missing or invalid packages. A cache becomes riskier when a build blindly executes a restored binary, trusts generated code without checks, or assumes restored metadata has integrity guarantees that the cache service does not promise.

Do not cache credentials, .env files, signing keys, cloud CLI state, private package tokens, or files containing customer data. A fork pull request may be able to restore a cache entry that was made available from a trusted branch. If the cache contains secrets, read-only access still exposes them. Masking a value in Actions logs does not protect it from a workflow that can read the file and send it over the network.

Separate cache from artifact semantics

Use a cache for dependencies or intermediate output that can be regenerated and whose integrity is validated by the consumer. Use an artifact to transfer a specific output from a job to another job or to preserve a build result after the run. Artifacts have explicit workflow and run relationships; caches are reusable by key and branch scope. Treating a cache as the approved release binary makes it difficult to prove which job produced it and whether it corresponds to the reviewed commit.

A safe release job should build from the reviewed source or download a run-specific artifact whose producer, commit, digest, and provenance have been validated. A cache can speed that build, but it should not determine what gets deployed. If the build output is restored from cache, validate it against the expected source and toolchain or rebuild the release output. For a high-assurance pipeline, cache only package download content and derived data that the build system can verify.

Use keys for correctness, not just hit rate

Start cache keys with a stable platform and tool identity, then include a hash of the lockfile or dependency manifest. A restore prefix may allow reuse when the exact lockfile key is absent, but it must be understood as a fallback to a different input state. Package managers commonly revalidate dependency versions against the lockfile; custom build caches may not. A broad prefix such as build- can restore data from a different compiler, branch, target architecture, or trust domain unless those facts are encoded elsewhere.

name: Verify dependencies
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: "22"
      - name: Restore package download cache
        uses: actions/cache/restore@v6
        with:
          path: ~/.npm
          key: npm-linux-node22-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            npm-linux-node22-
      - run: npm ci
      - run: npm test
      - name: Save cache from trusted default-branch push
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/cache/save@v6
        with:
          path: ~/.npm
          key: npm-linux-node22-${{ hashFiles('package-lock.json') }}

The example caches npm’s package download directory, not a node_modules tree that the workflow would execute without reinstalling. The save step is restricted to a push on the default branch, and the workflow has read-only repository contents permission. The action major version and Node runtime should be reviewed under your repository’s pinning policy; when pinning actions to full commit SHAs, update them through a reviewed dependency process. Self-hosted runners must meet the cache action’s documented minimum runner version.

This workflow still needs branch protection and review to ensure that a change to main is trusted. A branch name check does not independently prove provenance. A release workflow should also verify the source commit and artifact chain before publishing. On low-trust triggers, leave the platform’s default read-only access to the default-branch cache scope intact. GitHub lets a workflow or job explicitly request cache-mode: write or cache-mode: write-only; do not use those overrides on a job that handles untrusted input unless trusted consumers also treat every resulting cache as tainted.

Treat restore-key results as tainted

An exact key match is a stronger signal that the cache was created for the declared inputs, but it is not a cryptographic integrity proof. A prefix match can return a cache for an older lockfile or neighboring platform configuration. For package download caches, re-resolve and validate packages with the lockfile and package-manager integrity checks. For compiler or test caches, use the compiler’s own cache validation and include target architecture, toolchain, and configuration in the key. Never use a fallback to bypass signature or digest verification.

When using restore-keys, document what older data is safe to reuse and which command validates it. A stale cache can alter test behavior if the test accidentally skips a build step or uses a restored generated file. Make cache misses non-fatal unless there is a specific reproducibility requirement; a cache should not become an availability dependency. Periodically test a clean build with caching disabled so hidden dependencies on local cache state are found before an incident.

Understand pull-request and privileged workflow interaction

pull_request_target runs in a privileged base-repository context and needs special care. GitHub restricts the cache behavior of this event by default to reduce the risk that untrusted pull request code can write entries later consumed by trusted workflows. Checking out and executing a fork’s code in a privileged event can still expose secrets and tokens; cache restrictions do not make that workflow safe. Use pull_request for code validation whenever possible, and keep privileged events limited to trusted metadata operations.

Do not opt out of the default-branch cache restriction simply because a pull_request_target job reports a cache-save warning. A pull_request workflow can save to its merge-ref scope, but trusted release jobs should not consume those entries. Alternatively, save a release cache from a trusted push after merge. Keep cache paths narrow and inspect what is uploaded. Broad paths like the runner home directory can accidentally include credentials and tool configuration.

Diagnose poisoned, stale, or ineffective caches

When a build changes unexpectedly, compare the cache key, cache version, branch scope, lockfile digest, action version, and producer workflow run. Re-run the job with caching disabled and compare outputs. Check whether a restore prefix selected an older entry, whether the cache path includes generated source or binaries, and whether a prior job used a different operating system or tool version. Preserve run IDs and logs before evicting suspicious entries so the investigation can identify the producer and consumer.

If a cache is known to be unsafe, change the key namespace to prevent new consumers from selecting it, then remove affected entries through the supported cache management workflow. Do not assume deleting a workflow file revokes existing entries. Rotate any credential that may have been included, review network logs for exfiltration, and rebuild deployment artifacts from a clean source checkout. Caches are disposable, so do not delay incident containment to preserve hit rates.

Finally, measure cache value using total build time, network load, storage, and trust complexity. A high hit rate is not automatically a success if it couples unrelated jobs or makes releases difficult to reproduce. Cache only what can be safely regenerated, validate restored data at the consuming boundary, and keep artifact promotion independent from cache storage.

Related:

Sources:

Comments