GitHub Actions caching can significantly reduce workflow execution time by avoiding repeated downloads and builds. Package managers, build tools, and dependency-heavy projects can reuse previously generated data instead of starting from scratch on every run.

However, caching is not just a performance feature. It also creates a data-sharing boundary inside your CI/CD environment. If a workflow can restore or save cache data, that access needs to match the trust level of the code running in the workflow.

This becomes particularly important when workflows process pull requests, forked repositories, generated code, or other inputs that should not automatically receive the same privileges as trusted branch builds.

GitHub Actions provides cache access modes that let you control whether a workflow can restore existing caches, save new caches, do both, or avoid cache access completely.

Understanding when to use read, write, write-only, or none can help prevent cache poisoning while preserving the performance benefits of caching.

Why GitHub Actions Cache Security Matters

A cache may contain more than simple package downloads. Depending on how a project is configured, cached data can include dependency directories, compiler output, generated files, intermediate build artifacts, or other files produced during a workflow.

The important security rule is that cached content should be treated as untrusted input.

A workflow should not assume that data restored from a cache is safe simply because the cache was created by GitHub Actions. Cache contents are not a trusted secret store, and workflows should never use caches to store passwords, API keys, access tokens, or other sensitive credentials.

The risk becomes more significant when an untrusted workflow can write to a cache that a trusted workflow later restores.

For example:

  1. A contributor opens a pull request.

  2. The pull request workflow executes code supplied by that contributor.

  3. The workflow can write to a shared cache.

  4. The contributor-controlled workflow places unexpected data into the cache.

  5. A later trusted workflow restores that cache.

  6. The trusted workflow consumes the restored files.

The cache itself may not be the vulnerability. The problem is allowing untrusted code to influence data that trusted code will later consume.

Understanding GitHub Actions Cache Modes

GitHub Actions supports several cache access modes.

Mode

Restore Cache

Save Cache

Typical Use

read

Yes

No

Pull requests and other lower-trust workflows

write

Yes

Yes

Trusted branch builds

write-only

No

Yes

Dedicated cache population workflows

none

No

No

Workflows that do not need caching

The difference between these modes is important because restoring a cache and creating a cache are two different security capabilities.

read

The read mode allows a workflow to restore an existing cache but prevents it from saving a new cache.

This is usually the safest choice when a workflow needs caching for performance but is processing code that should not be allowed to modify the shared cache.

For example, a pull request workflow might use:

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

The workflow can benefit from an existing cache without gaining permission to populate or overwrite cache data.

write

The write mode allows both cache restoration and cache saving.

This is appropriate when the workflow is trusted to create cache content that other workflows may later consume.

A common example is a workflow triggered by pushes to a protected default branch:

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

A trusted branch workflow can restore an existing cache and save a new one when required.

The important consideration is not simply whether the workflow is running on GitHub Actions. The important question is whether the code executing in that workflow is trusted to influence future cache contents.

write-only

write-only is useful when a workflow should populate a cache but should not consume an existing cache.

This can be useful for dedicated cache-building jobs or workflows where you deliberately want to generate fresh cache content.

Conceptually:

Trusted build
     |
     v
Generate dependencies
     |
     v
Save cache

The workflow does not restore an existing cache before producing the new cache.

This mode can be useful when cache creation is intentionally separated from normal workflow execution.

none

The none mode disables cache restore and cache save operations.

Use it when caching is unnecessary or when the workflow handles particularly sensitive or untrusted input and the performance benefit does not justify the additional cache interaction.

For example:

- name: Run security-sensitive validation
  uses: actions/checkout@v4

- name: Run validation
  run: ./scripts/validate.sh

If the workflow does not need cached dependencies or build artifacts, there is little reason to give it cache access.

When Should a Workflow Use read?

Use read when the workflow needs existing cache data but should not be allowed to create new cache data.

Typical situations include:

A useful mental model is:

“This workflow may consume previously trusted cache data, but it must not establish new cache data.”

For example:

name: Pull Request Tests

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

The workflow can use a cache if one is available, but the pull request job does not need to become a cache producer.

When Should a Workflow Use write?

Use write when both cache restoration and cache creation are required and the workflow runs trusted code.

A typical pattern is a protected branch build:

name: Main Build

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

Here, the workflow is responsible for maintaining the cache used by future runs.

Before choosing write, verify:

Giving a workflow write access simply because it is faster is not a good security decision.

When Should a Workflow Use write-only?

write-only makes sense when cache creation is a dedicated operation.

Consider a repository with two workflows:

Cache Builder
     |
     +---- Creates cache
     |
     v
Pull Request Workflow
     |
     +---- Reads cache

The cache-building workflow can generate cache content from trusted sources, while pull request workflows receive read-only access.

This separation reduces the number of workflows that can modify shared cache state.

It is particularly useful when an organization wants to establish a clear distinction between:

That separation can make the security model easier to review.

When Should a Workflow Use none?

Not every workflow needs caching.

Use none when:

For example:

jobs:
  policy-check:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Check repository policy
        run: ./scripts/check-policy.sh

There is no reason to introduce cache access when the job does not benefit from it.

A Practical Cache Security Decision Matrix

The following decision process works well for most repositories.

Workflow Type

Recommended Mode

Reason

Pull request validation

read

Consume cache without creating it

Fork pull request

read or none

Keep cache privileges minimal

Trusted main build

write

Restore and update cache

Dedicated cache builder

write-only

Produce fresh cache data

Security-only workflow

none

No unnecessary cache access

Release workflow

write if required

Trusted workflow may maintain cache

Dependency audit

read or none

Usually does not need cache mutation

This is not a rule that every repository must follow exactly. The correct mode depends on the trust boundary and what the workflow actually needs.

How Cache Poisoning Can Happen

Consider a cache key like:

node-build-linux

Suppose a trusted workflow later restores this cache and uses its contents during a production build.

If an untrusted workflow can create or influence the same cache entry, the trusted workflow may consume files that were generated under a different trust level.

The problem becomes worse when the restored files are executable.

For example:

- name: Restore build cache
  uses: actions/cache/restore@v4
  with:
    path: .cache/build
    key: node-build-linux

- name: Execute cached tool
  run: .cache/build/tool

The workflow is now treating cached content as executable input.

A safer design is to ensure that cache-producing workflows are trusted and that untrusted workflows cannot write to the same cache scope.

Cache Keys Are a Security Boundary Too

Cache mode alone does not solve every cache-related problem.

Cache keys should be designed so that unrelated builds do not unnecessarily share data.

For example:

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

This is generally more meaningful than a broad key such as:

key: npm-cache

The dependency lock file becomes part of the cache identity, helping ensure that dependency changes result in a different cache key.

You can also include relevant runtime or architecture information when necessary:

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

The exact key structure should match the files being cached and the environments in which they are consumed.

Be Careful With Restore Keys

Restore keys provide fallback behavior when an exact cache key is not available.

For example:

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

This can improve cache hit rates, but broad fallback keys can also cause a workflow to consume older or less-specific cache content.

Use restore keys intentionally.

Ask:

A faster cache hit is not automatically a safer cache hit.

Never Use GitHub Actions Cache as a Secret Store

A cache should not contain:

Even if the cache is created by a trusted workflow, it should not be treated as a secure vault.

Use GitHub Actions secrets or an appropriate external secret-management solution for credentials instead.

The principle is simple:

Cache reusable build data, not authentication material.

actions/cache Versus Separate Restore and Save Actions

GitHub Actions supports both combined cache operations and separate restore/save operations.

The combined approach is convenient:

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

For more explicit control, separate actions can make the workflow easier to reason about:

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

- name: Install dependencies
  run: npm ci

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

This pattern can be useful when you want to make the restore and save stages explicit or when the workflow needs to control exactly when cache data is persisted.

Reusable Workflows Need the Same Security Thinking

Reusable workflows can centralize CI/CD logic, but they do not remove the need to define cache permissions carefully.

Suppose an organization provides a reusable build workflow:

jobs:
  build:
    uses: organization/shared-workflows/.github/workflows/build.yml@main

The called workflow may contain cache operations, but the caller and the reusable workflow should still be designed around the trust level of the calling context.

Do not assume that centralizing a workflow automatically makes every caller equally trusted.

Review:

Centralized workflows should make security easier to enforce, not hide it.

Common GitHub Actions Cache Security Mistakes

Giving Every Workflow Write Access

A common mistake is enabling cache writes everywhere because it produces more cache hits.

The result is a larger attack surface.

Prefer:

Trusted workflow -> write
Untrusted workflow -> read

instead of:

Every workflow -> write

Using One Broad Cache Key

A key such as:

build-cache

may cause unrelated executions to share more data than necessary.

Make cache keys reflect the actual dependency or build state.

Caching Sensitive Files

Do not cache directories simply because they are large or expensive to generate.

First determine exactly what those directories contain.

Executing Restored Cache Content

Restored cache data should be treated as untrusted input. Avoid automatically executing binaries, scripts, or generated files from a cache without understanding their provenance.

Assuming a Successful Cache Hit Means Trusted Data

A cache hit only means that matching cache data was found. It does not establish that every file inside that cache should be trusted as executable or security-sensitive input.

How to Troubleshoot Cache Permission Problems

If a workflow unexpectedly cannot save a cache, check the effective cache mode first.

Look at:

GitHub Actions also exposes the effective cache mode through the ACTIONS_CACHE_MODE environment variable.

A useful diagnostic step is:

- name: Show cache mode
  run: echo "Cache mode: $ACTIONS_CACHE_MODE"

This can help determine whether the job is actually running with the access level you expected.

Remember that a skipped cache save does not necessarily mean that the entire workflow failed. Cache operations can be unavailable or intentionally disabled while the remaining job continues.

Best Practices for GitHub Actions Cache Security

Follow these practices when designing production workflows:

  1. Use read for untrusted workflows whenever caching is useful.

  2. Use write only for workflows that genuinely need to maintain caches.

  3. Use write-only for dedicated cache-generation workflows.

  4. Use none when caching provides little value.

  5. Treat restored cache contents as untrusted input.

  6. Never store secrets or credentials in caches.

  7. Use specific cache keys based on meaningful dependency or build state.

  8. Review broad restore-keys carefully.

  9. Keep cache-writing workflows on trusted execution paths.

  10. Separate cache producers from cache consumers when practical.

  11. Avoid executing cached files without considering their provenance.

  12. Review cache permissions whenever workflow triggers change.

Advantages and Disadvantages of Cache Access Modes

Mode

Advantages

Disadvantages

read

Stronger isolation, good for PRs

Cannot update stale or missing cache

write

Best performance and cache freshness

Higher security responsibility

write-only

Separates cache production from consumption

May require an additional workflow

none

Smallest cache attack surface

No caching benefits

There is no universally best mode.

The correct choice is the least-privileged mode that still provides the performance and functionality the workflow requires.

A Better Way to Think About GitHub Actions Caching

Instead of asking:

“Which cache mode gives us the best performance?”

Ask:

“What is the minimum cache access this workflow needs?”

That change in perspective naturally leads to better CI/CD security.

For a trusted branch build, write may be appropriate because the workflow needs to restore and update reusable build data.

For a pull request, read may provide most of the performance benefit without allowing contributor-controlled code to create new cache entries.

For a dedicated cache population workflow, write-only can establish a clear producer role.

And for workflows that do not benefit from caching, none removes unnecessary access entirely.

Conclusion

GitHub Actions caching is valuable for reducing build times, but cache access should be treated as a CI/CD security decision rather than simply a performance setting.

The four modes provide a useful least-privilege model:

The most important principle is to prevent untrusted workflow execution from becoming a cache producer for data that trusted workflows will later consume.

When cache keys, workflow triggers, permissions, and cache modes are designed together, teams can keep the speed benefits of GitHub Actions caching without unnecessarily expanding the CI/CD attack surface.