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 |
|---|---|---|---|
| Yes | No | Pull requests and untrusted jobs |
| Yes | Yes | Trusted cache-building jobs |
| No | Yes | Jobs that only populate a cache |
| 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:
Pull request tests
Static analysis
Linting
Documentation validation
Security scanning
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 |
|
|
|---|---|---|
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:
Cache permissions
Workflow triggers
Secrets
Runner configuration
Third-party actions
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:
Is the workflow triggered only by trusted events?
Can untrusted code execute before the cache is saved?
Does the job actually need write access?
Can the cache contain credentials?
Is the cached path narrowly defined?
Are cache keys specific enough?
Are restore keys unnecessarily broad?
Can a privileged workflow later restore this cache?
Are third-party actions pinned and reviewed?
Is the runner environment appropriately isolated?
Is a read-only design sufficient?
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
Provides explicit cache access control.
Supports least-privilege workflow design.
Helps separate trusted cache creation from untrusted cache consumption.
Reduces the opportunity for cache poisoning.
Works at both workflow and job levels.
Supports read-only, write-only, read/write, and disabled modes.
Disadvantages
Requires teams to understand workflow trust boundaries.
Incorrect permissions can cause cache misses or warnings.
Broad write permissions can still create significant security risk.
Existing workflows may need restructuring.
Cache security also depends on what files are stored and how restored content is used.
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.

Join the conversation! Your thoughts help the community grow.