Back to all articles
TutorialPlatform Engineering8 min read

Secure Pipelines with GitHub Actions cache-mode

Zayd Zarrouk
Zayd ZarroukFounder & Product Engineer
github-actionsci-cdsecuritydevopscaching

To secure your CI/CD pipelines against cache poisoning, you can configure the GitHub Actions cache-mode parameter at the workflow or job level. This feature allows you to enforce least-privilege access, restricting untrusted jobs to read-only or completely blocking cache access to isolate sensitive execution environments from malicious dependencies.

As of September 10, 2026, GitHub announced the General Availability of cache-mode for GitHub Actions. This feature addresses a long-standing security gap in the Actions runner architecture: previously, the cache credential (ACTIONS_RUNTIME_TOKEN) was automatically distributed to every workflow run with write permissions, regardless of the job's explicit permissions: block. This created opportunities for attackers to poison caches via pull requests or compromised dependencies, which would subsequently be consumed by trusted release pipelines.

In this hands-on tutorial, you will learn how to configure and enforce cache-mode across your workflows to restrict cache access, isolate untrusted test runners, and safely propagate cache permissions through reusable workflows.

Understanding the Cache Poisoning Attack Vector

Before implementing defensive configurations, it is critical to understand the threat model that GitHub Actions cache-mode mitigates. In standard continuous integration setups, dependencies are cached using actions like actions/cache to speed up build times. When a workflow executes, it checks for an existing cache matching a specific key. If a match is found, the files are restored; if not, a new cache is saved at the end of a successful run.

The core vulnerability stems from the shared nature of the cache namespace on a branch. While GitHub enforces branch scoping boundaries to prevent a pull request from a fork from directly overwriting the main branch cache, internal pull requests or compromised automated workflows could write malicious payloads into the cache. For example, an attacker could submit a pull request that modifies a dependency build script to inject a backdoor, execute the build to save the poisoned payload under a common cache key, and then close the pull request. When a trusted workflow (such as a nightly release build or a deployment pipeline on the base branch) later runs, it might restore that poisoned cache, compiling the backdoor into the production artifact.

As discussed in the GitHub Community Cache Isolation Discussion, build attestations such as SLSA provenance or Sigstore signatures cannot detect these tampered inputs because the build itself runs in the expected, authorized environment. By limiting which jobs can write to the cache, you eliminate this attack vector entirely.

Syntax and Supported Modes for GitHub Actions cache-mode

The cache-mode configuration is defined directly in your workflow YAML files. It can be declared at the top-level of a workflow file (applying to all jobs by default) or overridden at the individual job level. Job-level settings always take precedence over workflow-level settings.

According to the official GitHub Actions cache-mode Changelog, the configuration supports four distinct values:

  • read: Allows the job to restore existing caches but prevents it from saving new ones. This is the secure default for low-trust triggers such as pull_request_target.
  • write: Allows both cache restores and saves. This is the default behavior for trusted triggers like push.
  • write-only: Allows the job to save new caches but prevents it from restoring existing ones. This is useful for clean-room build jobs that must populate a fresh cache without risk of consuming old, potentially tainted data.
  • none: Completely blocks all cache access. Neither restores nor saves are permitted. This is ideal for executing untrusted scripts, running tests on external code, or isolating agentic workflows.

During execution, the selected mode is enforced directly by the GitHub Actions cache service using scoped cache tokens, and the active mode is exposed to the runner via the ACTIONS_CACHE_MODE environment variable.

Let's run a quick simulation of how the runner evaluates these permissions using Node.js:

const evaluateCacheAccess = (mode, actionType) => { 
  const permissions = {
    'read': ['restore'],
    'write': ['restore', 'save'],
    'write-only': ['save'],
    'none': []
  };
  return (permissions[mode] || []).includes(actionType);
};

const testModes = ['read', 'write', 'write-only', 'none'];

testModes.forEach(mode => {
  console.log(`Mode: ${mode.padEnd(10)} | Can Restore: ${evaluateCacheAccess(mode, 'restore')} | Can Save: ${evaluateCacheAccess(mode, 'save')}`);
});

Step-by-Step Implementation: Hardening a Node.js CI Pipeline

In this section, we will build a complete, production-ready GitHub Actions workflow that implements the principle of least privilege. We will set a restrictive default at the workflow level, grant write access only to a dedicated setup job, and completely isolate our testing job.

Prerequisites

  • A GitHub repository with actions enabled.
  • A modern runner version (minimum runner version 2.327.1 is recommended for Node 24-based cache actions as documented in the actions/cache repository).

Step 1: Set the Workflow-Level Default

Start by creating a workflow file at .github/workflows/ci.yml. We will set the default cache-mode to read at the root level. This ensures that any job we define will, by default, be unable to save or overwrite caches unless explicitly permitted.

name: Hardened CI Pipeline

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

# Secure default: no job can write to the cache unless overridden
cache-mode: read

jobs:
  # Jobs will be defined here

Step 2: Define the Cache-Populating Job

Next, we define a job that is responsible for installing dependencies and saving them to the cache. Because this job needs to save the cache, we override the workflow default by setting cache-mode: write on this job specifically.

  setup-and-cache:
    runs-on: ubuntu-latest
    cache-mode: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with: 
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

What to check: When this job runs on a trusted branch, the cache service issues a token with write privileges. At the end of the step, the setup-node action will successfully save the npm cache to the GitHub Actions cache store.

Step 3: Define the Isolated Test Job

Now, we define a testing job that executes our unit tests. Tests often run external code or user-submitted scripts. To prevent any malicious script from accessing or poisoning our cache, we set cache-mode: none on this job.

  run-tests:
    needs: setup-and-cache
    runs-on: ubuntu-latest
    cache-mode: none
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      # Even if setup-node is configured to cache, cache-mode: none blocks all access
      - name: Run test suite
        run: npm test

What to check: Even if a dependency or test script attempts to invoke the cache API manually during this job, the cache service will reject the request immediately because the runtime token does not possess any cache permissions.

Reusable Workflows and Propagation Rules

When designing enterprise CI/CD systems, you often rely on reusable workflows to standardize build and deployment steps. The cache-mode configuration has strict propagation rules designed to prevent privilege escalation:

  1. The cache-mode configuration propagates from the caller workflow to the called (reusable) workflow.
  2. A called workflow can never receive more cache access than what was granted by its caller.
  3. If a called workflow declares a cache-mode that requires higher privileges than the caller's limit (for example, if the caller is restricted to read but the called workflow requests write), the workflow run will fail to start and report a validation error.

Because read and write-only are non-overlapping capabilities, a mismatch between them is treated as an over-request. For example, if your caller workflow sets cache-mode: write-only, it cannot call a reusable workflow that declares cache-mode: read.

Here is an example of a secure caller workflow calling a reusable build workflow:

name: Caller Workflow

on:
  push:
    branches: [ "main" ]

jobs:
  call-reusable-build:
    # Restrict the called workflow to read-only cache access
    cache-mode: read
    uses: ./.github/workflows/reusable-build.yml@main

For more detailed syntax on caching and workflow structures, refer to the GitHub Dependency Caching Reference.

Troubleshooting Common Errors and Validation Failures

When adopting cache-mode, you may encounter validation errors or warning annotations. Below are the most common issues and how to resolve them.

1. Reusable Workflow Validation Failures

If your workflow fails with a startup validation error similar to:

Error: Called workflow requested 'write' cache-mode, which exceeds the caller's 'read' restriction.

The Fix: Ensure that the caller workflow does not restrict the cache mode below what the called workflow requires. If the called workflow must write to the cache, the caller's job calling it must also be configured with cache-mode: write.

2. Low-Trust Override Warnings

If you explicitly set cache-mode: write or cache-mode: write-only on workflows triggered by low-trust events like pull_request_target, GitHub Actions will append a warning annotation to the run. This is a built-in platform safeguard warning you that the job is exposed to cache poisoning risks.

To review these pull request runs and security warnings in detail, you can navigate to your repository's actions tab or inspect them on the Refreshed Repository Pull Requests Page.

The Fix: Remove the explicit write override for low-trust triggers, or isolate the cache-saving steps to trusted push events on your main branch.

3. Cache Misses on Clean-Room Builds

If you configure cache-mode: write-only and notice that your jobs are always performing full clean installs without restoring any cached files, this is the expected behavior. write-only blocks all read actions. If you want to speed up the build by restoring previous caches before saving the new state, you must use cache-mode: write.

Next Steps

Now that you have hardened your standard CI/CD pipelines, consider applying these patterns to more advanced workflows. For instance, if you are building AI-driven systems or integrations that execute untrusted natural language inputs—such as those described in our guide on Building Natural Voice Experiences with GPT-Live-1—you should always enforce cache-mode: none as a robust defense-in-depth measure to completely isolate your runner's cache surface from the agent's runtime environment.

Frequently asked questions

What is the default cache-mode in GitHub Actions?

By default, trusted events like push use write mode (allowing both restores and saves), while low-trust events like pull_request_target default to read mode to protect the repository.

Can a reusable workflow request higher cache permissions than its caller?

No. The cache-mode propagates from the caller workflow. If a called workflow requests higher permissions than the caller has granted, the run fails with a validation error.

What environment variables are available during runtime for cache-mode?

The runtime environment variable ACTIONS_CACHE_MODE is populated with the active mode (read, write, write-only, or none), and the job context contains job['cache-mode'].

Sources

  1. GitHub - actions/cache: Cache dependencies and build outputs in GitHub Actions — GitHub
  2. Help Us Improve GitHub Actions Cache Isolation 🔧 · community · Discussion #194493 — GitHub
  3. Control GitHub Actions cache access with cache-mode — The GitHub Blog
  4. Dependency caching reference - GitHub Docs — GitHub Docs

Continue exploring