Caching is one of the easiest ways to speed up a GitHub Actions workflow. Dependency downloads, package-manager caches, and intermediate build files can be reused instead of being generated from scratch on every run.

The security problem is that a cache is not just a performance optimization. A workflow that can write a cache can potentially influence what a later workflow restores and executes.

This becomes especially important when a workflow processes untrusted code, such as pull requests from forks or other events that can be initiated by users without repository write access.

GitHub Actions provides the cache-mode workflow setting to control whether a job can read, write, both, or neither. The available modes are read, write, write-only, and none. GitHub enforces the selected access through scoped cache tokens.

The key security principle is simple:

A workflow should receive only the cache permissions it actually needs.

Why GitHub Actions Caches Can Become a Security Problem

Consider a typical CI workflow:

Pull Request
     |
     v
Checkout Code
     |
     v
Restore Cache
     |
     v
Install Dependencies
     |
     v
Run Tests

Now imagine that the workflow can also write to the shared cache.

An attacker who can influence an untrusted workflow may attempt to place malicious content into a cache. A later trusted workflow could restore that content and execute it.

This is known as cache poisoning.

GitHub specifically warns that caches are not signed or verified and that restored cache contents should be treated as untrusted input.

The risk is particularly important when cached files are later executed.

For example:

Untrusted workflow
      |
      v
Malicious cache content
      |
      v
Trusted workflow restores cache
      |
      v
Build/test executes cached content

The cache therefore becomes part of the workflow's attack surface.

What Is cache-mode?

cache-mode controls the cache permissions available to a workflow or job.

GitHub currently supports four modes:

Mode

Restore Cache

Save Cache

Typical Use

read

Yes

No

Pull requests and untrusted jobs

write

Yes

Yes

Trusted cache-building jobs

write-only

No

Yes

Jobs that only populate a cache

none

No

No

Jobs that should not use caching

The setting can be applied at the workflow level:

cache-mode: read

or to an individual job:

jobs:
  build:
    runs-on: ubuntu-latest
    cache-mode: read

A job-level setting overrides the workflow-level setting for that job.

Why Least Privilege Matters

The principle of least privilege says that a process should receive only the permissions required to perform its task.

The same idea applies to caching.

If a job only needs to restore dependencies, it should not have permission to create or overwrite caches.

Use:

cache-mode: read

instead of:

cache-mode: write

when the job does not need to save anything.

This creates a much smaller security boundary.

A Read-Only Cache Workflow

Suppose a pull request workflow needs to restore an npm cache.

A simplified workflow could look like this:

name: Pull Request Checks

on:
  pull_request:

cache-mode: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Restore npm cache
        uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

The important security property is not the specific package manager.

The important property is that the job can restore a cache but cannot save a new one.

Trusted Workflows Can Use Write Access

A trusted workflow can be responsible for creating and updating caches.

For example:

name: Build

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    cache-mode: write

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Cache npm
        uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

A push to the default branch is generally a trusted workflow context when only authorized contributors can modify the branch.

GitHub's default cache restrictions allow trusted triggers such as push to create or overwrite caches in the default branch's cache scope.

What Happens on Low-Trust Triggers?

GitHub applies additional restrictions to workflows triggered by events that can be influenced by users without repository write access.

For example, certain low-trust events receive read-only access to caches in the default branch's scope.

This means a workflow can do:

Restore existing cache

but cannot do:

Create or overwrite default-branch cache

GitHub identifies this restriction as a mitigation against cache poisoning.

The exact behavior depends on the trigger and cache scope, so security-sensitive workflows should explicitly define their intended cache access.

The Four cache-mode Values

read

Use read when the job needs to restore existing cache content but should never create or modify a cache.

cache-mode: read

This is a strong default for untrusted or read-only CI jobs.

Typical examples include:

write

write allows both restoring and saving caches:

cache-mode: write

Use this for trusted jobs that intentionally maintain the cache.

Because it provides more capability, it should not be granted automatically to every job.

write-only

write-only allows a job to save caches without restoring existing caches:

cache-mode: write-only

This is useful in workflows designed to generate a fresh cache without consuming existing cache content.

Because the job cannot restore a cache, it is also isolated from potentially untrusted existing cache content.

none

none disables cache access:

cache-mode: none

Use it when caching provides no value or when a job should have no cache dependency.

This can make security-sensitive workflows easier to reason about.

Why write on Untrusted Workflows Is Dangerous

GitHub warns that explicitly setting:

cache-mode: write

or:

cache-mode: write-only

on a low-trust trigger can override the default read-only protection.

Consider:

on:
  pull_request_target:

jobs:
  build:
    cache-mode: write

If the workflow checks out and processes attacker-controlled pull-request code before saving the cache, the attacker may be able to influence the contents written to a cache that a more privileged workflow later restores.

The security issue is therefore not simply "cache write permission."

It is the combination of:

Untrusted input
      +
Cache write access
      +
Later privileged cache restore

That combination requires careful review.

A Safer Pull Request Pattern

For a pull request workflow, use read-only caching:

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    cache-mode: read

    steps:
      - uses: actions/checkout@v6

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

      - run: npm ci
      - run: npm test

Using the dedicated restore action also communicates the workflow's intent clearly.

GitHub recommends restore-only cache operations in low-trust workflows when the workflow should not attempt to save a cache.

Separate Cache Population from Untrusted Testing

A useful architecture is to separate trusted cache creation from untrusted code testing.

Trusted push
     |
     v
Build cache
     |
     v
Default branch cache

Pull Request
     |
     v
Read existing cache
     |
     v
Run tests

The pull request workflow benefits from the cache without receiving permission to modify the trusted cache.

This reduces the attack surface while retaining the performance benefit of caching.

Cache Contents Should Be Treated as Untrusted

Even a read-only cache should not automatically be treated as trustworthy.

GitHub explicitly notes that cache contents are not signed or verified. A workflow that can read a cache can extract its contents.

Therefore, avoid storing:

API tokens
Passwords
Private keys
Cloud credentials
Authentication cookies
Environment secrets

in cached paths.

For example, do not cache an entire home directory:

path: ~/

Instead, cache only the specific dependency directory required by the build.

For npm:

path: ~/.npm

For other package managers, choose the appropriate package cache rather than a broad filesystem location.

Cache Keys Are Part of the Security Design

A cache key determines which cache an action attempts to restore.

A typical dependency key is:

key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

This is preferable to a generic key such as:

key: npm-cache

because dependency changes produce a different cache key.

You can also include the runtime version:

key: npm-${{ runner.os }}-node24-${{ hashFiles('package-lock.json') }}

The exact key should reflect the files and environment that determine whether cached content is compatible.

Be Careful with restore-keys

A workflow can define fallback keys:

restore-keys: |
  npm-${{ runner.os }}-
  npm-

This can improve cache reuse, but broad fallback keys can restore older content.

The application should therefore be able to regenerate dependencies correctly when the cache is incomplete or stale.

GitHub documents that cache restoration first searches for an exact key and then uses partial matches and restore-keys when configured.

Reusable Workflows Need Special Attention

Reusable workflows can make cache permissions less obvious.

Suppose a caller job has:

jobs:
  test:
    uses: ./.github/workflows/test.yml
    cache-mode: read

The caller's cache permission can limit what the called workflow is allowed to request.

GitHub documents that an explicit cache-mode limit can propagate into reusable workflows, and a called workflow requesting more access than the caller allows can cause workflow validation to fail.

This is important for organizations that centralize CI logic.

A reusable workflow should not silently assume write access just because it happens to run in a trusted repository.

cache-mode vs actions/cache/restore

These mechanisms solve related but different problems.

Feature

cache-mode

actions/cache/restore

Controls permission

Yes

No

Restores cache

Depending on mode

Yes

Saves cache

Depending on mode

No

Expresses intent

Yes

Yes

Prevents write access by itself

Yes

Restore action itself does not save

Useful for low-trust workflows

Yes

Yes

A strong configuration can use both:

cache-mode: read

and:

uses: actions/cache/restore@v6

The first establishes the permission boundary. The second makes the individual cache operation explicitly restore-only.

Common Mistakes

Giving Every Job write Access

This is unnecessarily broad:

cache-mode: write

at the workflow level when only one job needs to populate the cache.

Instead:

cache-mode: read

jobs:
  build-cache:
    cache-mode: write

  test:
    cache-mode: read

This gives each job the smallest useful permission.

Caching Secrets

Never cache files containing credentials.

If a dependency directory can contain authentication information, configure the package manager so credentials are stored separately and excluded from the cache.

Using Broad Cache Paths

Avoid:

path: .

or other paths that could include source files, credentials, generated scripts, or configuration unexpectedly.

Cache only what is required.

Trusting Restored Files Automatically

A cache hit does not mean the files are safe.

Treat restored cache content as untrusted input.

Enabling Write Access on pull_request_target

This can defeat GitHub's default read-only protection and reintroduce cache-poisoning risk.

Combining Cache Changes with Workflow Refactoring

When security is involved, keep changes focused.

Changing:

at the same time makes security review harder.

Troubleshooting Cache Permission Problems

Cache Restore Is Skipped

Check the effective cache-mode.

If the mode is:

cache-mode: none

or:

cache-mode: write-only

a restore operation is not permitted.

GitHub exposes the effective mode to the runner through ACTIONS_CACHE_MODE.

Cache Save Produces a Warning

A read-only workflow may successfully restore a cache but receive a warning when the action attempts to save one.

This does not necessarily mean the workflow failed.

If the workflow is intentionally restore-only, use:

actions/cache/restore

to make that behavior explicit.

A Reusable Workflow Fails Validation

Check whether:

Caller cache-mode
        ↓
Called workflow cache-mode

requests are compatible.

A caller that explicitly limits a job to read cannot safely invoke a reusable workflow that requires write.

Production Security Checklist

Before enabling cache writes, ask:

  1. Is the workflow triggered only by trusted events?

  2. Can untrusted code execute before the cache is saved?

  3. Does the job actually need write access?

  4. Can the cache contain credentials?

  5. Is the cached path narrowly defined?

  6. Are cache keys specific enough?

  7. Are restore keys unnecessarily broad?

  8. Can a privileged workflow later restore this cache?

  9. Are third-party actions pinned and reviewed?

  10. Is the runner environment appropriately isolated?

  11. Is a read-only design sufficient?

  12. Can a trusted workflow populate the cache instead?

If the answer to the third question is no, use:

cache-mode: read

or:

cache-mode: none

depending on whether the job needs cached data at all.

Best Practices

Use Read-Only Access for Untrusted Jobs

cache-mode: read

should be the preferred model when a job only needs existing dependencies.

Give Write Access Only to Trusted Jobs

Use:

cache-mode: write

only when the workflow genuinely needs to create or update cache entries.

Keep Cache Paths Narrow

Cache only dependency or build directories that are safe to reuse.

Separate Cache Population

Use trusted branch builds to populate shared caches.

Avoid Sensitive Data

Treat cache contents as potentially readable by workflows that can access the cache.

Use Specific Cache Keys

Include operating-system, runtime, and dependency-lock information where relevant.

Review Reusable Workflows

Cache permissions can cross workflow boundaries, so review the caller and called workflow together.

Advantages and Disadvantages

Advantages

Disadvantages

Conclusion

GitHub Actions caching should be treated as part of the workflow's security model, not merely as a performance optimization.

The cache-mode setting provides a useful way to apply least privilege: use read when a job only needs existing cache data, write only for trusted cache-building jobs, write-only when a job must create cache content without consuming existing cache data, and none when caching is unnecessary.

The safest architecture is usually to let trusted workflows populate shared caches while untrusted workflows consume them in read-only mode.

Most importantly, remember that a cache is executable supply-chain data when its contents influence later build steps. Do not store secrets in it, do not blindly trust restored files, and do not grant cache-write permissions to workflows that process untrusted input unless there is a carefully reviewed reason to do so.

With explicit cache permissions, narrow cache paths, controlled triggers, and carefully designed workflow boundaries, teams can keep the performance benefits of GitHub Actions caching without unnecessarily expanding the CI/CD attack surface.