Skip to content

    Back to articles

    5 min read

    Treat GitHub Actions cache reuse as a trust boundary

    A cache is shared state

    I treat a build cache as shared state, not as a harmless speed switch. A workflow that restores a cache reads files produced by another run; one that saves a cache makes its output available to later runs. That creates two distinct questions: who can write data that another workflow will consume, and who can read data a workflow has saved?

    The risk is not theoretical. GitHub warns that untrusted workflows can read sensitive cache contents, and that a poisoned cache can lead to code execution in a trusted workflow. Its dependency caching reference describes cache access restrictions for low-trust triggers and the risk of overriding them. A cache does not need to contain an obvious secret or executable for it to matter. Build tools may interpret restored files, and a trusted job may rely on cached state without checking who created it.

    That is why I ask about the writer and reader before I ask how much time a cache saves. Cache keys help select entries; they are not an authorization policy. A key that looks specific does not prove that its contents came from a workflow I trust. GitHub’s cache key matching documentation explains exact and partial matches, including how restore keys can select a partial match. I would not treat a key name as a boundary between trusted and untrusted work.

    Make access explicit

    GitHub now provides cache-mode to control a job’s cache access. The documented modes are read, write, write-only, and none; access is enforced with scoped cache tokens. The workflow syntax for cache-mode also allows the setting at workflow or job level, with a job setting overriding the workflow setting.

    For a workflow that handles pull requests, I would say what it needs rather than leave a security-sensitive default implicit:

    name: Pull request checks
    
    on:
      pull_request:
    
    cache-mode: read
    
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v5
          - uses: actions/cache@v4
            with:
              path: ~/.cache/example
              key: example-${{ runner.os }}-${{ hashFiles('dependency.lock') }}
          - run: ./build-and-test.sh

    This is an illustration of the access policy, not a complete build configuration. The cache path, key, and command need to match the project. The important choice is read: the job can benefit from a cache but cannot publish its own contents for a trusted job to consume.

    GitHub documents a trigger-based default when cache-mode is omitted: trusted triggers receive write, while low-trust triggers receive read. It also warns that explicitly setting write or write-only on a low-trust trigger overrides the read-only restriction. I prefer to make the intended mode visible in the workflow or reusable-workflow interface anyway. The cache access defaults and override guidance are worth reading before standardising a shared pipeline.

    For cache creation, I would use a trusted workflow that runs after a trusted push, and let pull-request checks read what that workflow produced. GitHub recommends saving caches from trusted triggers and maintaining the default-branch cache with a trusted workflow. That separation makes the policy understandable: untrusted input can benefit from existing cache state without getting permission to update state used by later trusted builds.

    Reusable workflows need a ceiling

    A shared workflow is not automatically a security boundary. It is a way to reuse pipeline behavior, and its cache permissions need to be part of the contract between caller and callee. GitHub says cache-mode propagates to reusable workflows, and an explicit mode on the calling job can limit the access the called workflow may request. The reusable workflow cache access documentation describes this behavior.

    There is a detail I would review carefully: if a calling job neither sets nor inherits an explicit cache-mode, a called workflow can explicitly request write even when a low-trust trigger would otherwise default to read. So I would not assume the trigger default alone constrains every layer of a shared pipeline. I would set the caller’s limit and inspect what the reusable workflow requests. That turns least privilege into an enforceable interface rather than a convention buried in a template.

    I also would not grant write access simply because a job needs caching. A job that only consumes cached dependencies needs read; one that must create cache entries needs a reason to write. If a low-trust job must save something for its own later use, write-only may be an option to examine, but it still permits a write from untrusted input. I would keep that state away from trusted release paths unless I can establish that the job cannot put untrusted material into it. GitHub’s guidance is explicit that write-capable modes on low-trust triggers can reintroduce cache-poisoning risk.

    Keep cache contents out of the trust decision

    I would design a pipeline so that a cache miss costs time, not confidence. A trusted build should still validate its inputs and produce its result from the checked-out source and dependency definitions; the cache should accelerate that work, not decide whether the output is trustworthy. If restoring a cache can silently replace an input that the build assumes is authoritative, I would narrow the cached path or remove that cache from the trusted path.

    This also affects what I choose to cache. I would avoid paths that may contain credentials or other sensitive material, because cache readers may be able to access saved contents. GitHub specifically advises against storing sensitive information in a cache in its dependency caching security guidance. I would cache reproducible, regenerable data where possible, and make the build work correctly when no cache is available.

    For teams sharing pipeline patterns across many repositories, I would put the access decision in the reusable workflow and make callers inherit or set an explicit ceiling. Then I would review changes to cache paths, keys, restore keys, triggers, and cache modes as security-relevant changes. The performance optimization stays useful; the trust decision stops being accidental.

    Drafted with AI assistance, reviewed and edited by Ali.