How Nx remote caching works and why a cache hit is a trust decision

Before Nx runs a task, it computes a hash from everything the task declares as input. That includes the source files matched by the task's input patterns, the hashes of the tasks it depends on, relevant configuration, the resolved versions of external dependencies, and any runtime values or environment variables you have listed. If the local cache has no entry for that hash, Nx asks the remote cache. On a remote hit, Nx downloads the stored outputs, such as dist folders and coverage reports. It also restores the recorded terminal output and replays it, so the task looks as if it ran. The task itself never executes.

Teams accept this setup because it shares work. One CI agent builds a library, and every later pipeline and every developer laptop with the same inputs gets the result in seconds instead of rebuilding it. In a large monorepo, most tasks in a typical pipeline end up as cache hits.

That is also why the threat model matters. The hash is only a lookup key. It is not proof that the artifact stored under it was produced from those inputs. Nx does not rebuild the artifact to check it, and the cache cannot tell an honest upload from a forged one. Anyone holding a write credential can compute the hash an honest machine will produce for a future commit and store arbitrary content under it, such as a backdoored bundle or a test result that always passes. Every reader that later computes that hash will restore the forged content and trust it.

So every cache hit is a trust decision about whoever wrote that entry. Nx remote cache security comes down to controlling who can write.

  declared inputs: sources, dep task hashes, config,
  external dep versions, listed env vars, runtime values
                               |
                               v
                      +-----------------+
                      |   task hash H   |  lookup key, not proof of origin
                      +-----------------+
                               |
                               v
                      +-----------------+
                      |   local cache   |
                      +-----------------+
                               | miss
                               v
 +--------------+   +-----------------------+   +-----------------------+
 | honest CI    |   |     remote cache      |   | reader: CI agent or   |
 | agent builds |-->|  key H -> outputs     |-->| developer laptop      |
 | from inputs  |   |  + terminal log       |   | restores dist, logs;  |
 +--------------+   +-----------------------+   | task never executes   |
                               ^                +-----------------------+
 +--------------+              |
 | any holder   |              |
 | of a write   |--------------+
 | credential   |   forged content written under the same key H
 +--------------+

A remote cache hit restores whatever is stored under hash H. Readers cannot tell whether an honest CI build wrote that entry or another holder of a write credential forged it.

Cache poisoning paths you need to close

controlling who can write entries. A reader cannot distinguish a forged artifact from a genuine one, and a write token typically lets its holder upload content under any hash, including hashes that main will compute later. Malicious code does not need to land in a commit. It only needs to run once somewhere that holds write access.

Path Attacker Access needed Closed by
Fork or untrusted-branch PR with a read-write token Anyone who can open a pull request A workflow that exposes the write token to PR-controlled code Read-only tokens for PR jobs (section 3)
Developer laptop with write access Malware on the machine, a malicious insider, or an honest developer with dirty local state A personal read-write token Read-only tokens for humans, with writes only from trusted CI (section 3)
Compromised CI runner or build dependency Whoever controls a runner image, install script or build plugin Code execution during a cacheable task Ephemeral runners, pinned lockfiles and isolated release builds (sections 4 and 5)
Leaked token Anyone who reads the repo, CI logs or uploaded artifacts A token committed to the repo or printed to output Secret scoping, log masking and rotation (sections 3 and 7)

Look-alike failures

Some bad hits are not attacks. Suppose a task reads an environment variable, a global tool version or a file outside its declared inputs. Two different builds then produce the same hash, and the second build restores the first build's output. The symptom looks like poisoning: the restored artifact is wrong and nobody changed the code. The difference is reproducibility. A false hit recurs deterministically from trusted runners. Poisoning traces back to an entry written by an unexpected origin. Declaring inputs completely (section 4) removes false hits. It does nothing against a hostile writer.

Implementing read-only cache tokens and CI secret scoping

The fix for unexpected origins is to make sure only one kind of job can write to the cache. Issue two credentials. The read-write token goes only to CI jobs that build already-reviewed code, such as pushes to main after merge. Everything else gets a read-only token: pull request jobs, fork builds and developer laptops. Untrusted jobs still benefit from cache hits, but they cannot plant entries that trusted builds will later restore.

Context Token Where it lives
Push to protected main Read-write Environment-protected CI secret
Same-repo PR Read-only Repository-level CI secret
Fork PR Read-only or none Never the write secret
Local development Read-only Developer's shell environment

Scoping the secret is what enforces this. Defining two tokens does not help if a PR workflow can still read both. In GitHub Actions, put the write token in an environment restricted to protected branches, and reference that environment only from post-merge jobs. Secrets are withheld from fork PRs on the pull_request trigger by default. Avoid pull_request_target for builds, because it runs with base-repository secrets. Other CI systems offer equivalents, such as protected variables or branch-filtered contexts.

Inject the token at runtime through NX_CLOUD_ACCESS_TOKEN rather than hardcoding it in nx.json. The exact config keys and variable names for Nx Cloud and self-hosted backends have changed across Nx versions, so check the documentation for your version.

If you commit a read-only token, treat it as public. Anyone with the repository can use it to download cached build outputs and terminal logs, which may contain internal paths or leaked environment values.

# .github/workflows/ci.yml
on:
  pull_request:        # fork PRs get no repository secrets on this trigger
  push:
    branches: [main]   # post-merge builds of reviewed code

jobs:
  pr-build:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    env:
      # Read-only token: cache hits allowed, no writes.
      # Empty for fork PRs, so the build simply runs uncached.
      NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CACHE_READ_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx nx affected -t build test

  main-build:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    # Write token is an environment secret. Restrict this environment's
    # deployment branches to main so no other ref can read it.
    environment: cache-writer
    env:
      NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CACHE_WRITE_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx nx affected -t build test --base=HEAD~1

A GitHub Actions workflow that gives PR jobs a read-only Nx cache token and limits the read-write token to main pushes through an environment-protected secret, injected via NX_CLOUD_ACCESS_TOKEN. Check the variable name for your Nx version.

Hardening CI trust boundaries, inputs and self-hosted backends

Keep trust levels on separate runners

A write token is only as safe as the machine holding it. Suppose pull-request jobs from forks or unreviewed branches share a persistent self-hosted runner with main-branch jobs. The untrusted job can read leftover environment files or credential helpers. It can also plant files in the workspace, such as a modified node_modules or a tampered local cache directory, which the next trusted job then uploads as a legitimate artifact. Use ephemeral runners that are destroyed after every job, or give each trust level its own runner pool, workspace and credentials.

Declare every input

A cache can be wrong without an attacker. If a task's output depends on something Nx does not hash, two different builds share one cache key and the wrong output gets replayed. Define namedInputs for production sources that exclude tests. Add runtime inputs for toolchain versions and env inputs for variables that change build output, as the example below shows. Any flag, tool or variable that changes output belongs in the hash.

Lock down the storage layer

On a self-hosted backend, tokens only govern the cache server's API. If a broad CI role, a developer group or another service can write to the underlying bucket directly, your token scheme is bypassed. Write the bucket policy so that only one dedicated write identity can create or overwrite objects. Give readers a separate read-only identity, block public access, and restrict network access to the cache endpoint and bucket to your CI network.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/package-lock.json"
    ],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.test.ts",
      "!{projectRoot}/jest.config.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": [
        "production",
        "^production",
        { "runtime": "node --version" },
        { "runtime": "npm --version" },
        { "env": "NODE_ENV" },
        { "env": "API_BASE_URL" }
      ]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"]
    }
  }
}

An nx.json where build hashes include production sources only (test files excluded), shared root config, dependency sources, the Node and npm versions, and the environment variables that change build output, so a different toolchain or environment produces a different cache key.

Release builds, verification and detection

Even with write access locked down, release artifacts deserve a stricter standard than pull request feedback. A poisoned entry that only slows a developer's test run is a nuisance. The same entry inside a deployable bundle reaches production. For production and deploy builds, either skip the cache entirely (Nx supports --skip-nx-cache) or only accept entries written by your trusted main-branch pipeline. The cost is a slower release job. In exchange, the artifact you ship is one you built from source in an environment you control.

Rebuild-and-compare verification

For everyday builds, verify the cache by sampling. On a schedule, pick a small set of cached tasks, rebuild them with the cache skipped, and compare the fresh outputs against the cached ones. A mismatch means one of two things: the task is non-deterministic, or the entry was tampered with. Both are worth knowing.

Byte-for-byte comparison rarely works on the first attempt. Common sources of noise:

  • Embedded timestamps.
  • Absolute paths baked into source maps.
  • Archive file ordering.
  • Bundlers that generate chunk names non-deterministically.

Normalize before comparing. Strip or pin timestamps, rewrite paths relative to the workspace root, and sort archive contents. Then treat any remaining difference as a signal.

Auditing writes

If your backend records which credential performed each write, as most object stores can through access logs, review that data. Alert on any write that did not come from the dedicated write identity. Also alert on writes from that identity that occur outside main-branch pipeline runs. Unexpected write activity from a feature branch or a developer machine is the clearest early sign of a leaked token.

#!/usr/bin/env bash
# Rebuild-and-compare check for one cached Nx task. Run on a schedule
# from a fresh CI runner (empty local cache) so step 1 hits the remote cache.
set -euo pipefail

PROJECT="${1:-web-app}"            # placeholder project name
OUT="dist/apps/${PROJECT}"          # adjust to the task's configured outputs
ROOT="$(pwd)"
WORK="$(mktemp -d)"

# 1. Normal run: outputs are restored from the remote cache if an entry exists.
npx nx run "${PROJECT}:build"
cp -r "$OUT" "$WORK/cached"

# 2. Same task built from source, bypassing the cache entirely.
rm -rf "$OUT"
npx nx run "${PROJECT}:build" --skip-nx-cache
cp -r "$OUT" "$WORK/fresh"

# 3. Normalize known noise so only meaningful differences remain.
normalize() {
  find "$1" -type f \( -name '*.js' -o -name '*.map' -o -name '*.json' \) -print0 |
    xargs -0 -r sed -i -E \
      -e "s#${ROOT}/##g" \
      -e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z/<TIMESTAMP>/g'
}
normalize "$WORK/cached"
normalize "$WORK/fresh"

# 4. Any remaining difference is either non-determinism or tampering.
if ! diff -r "$WORK/cached" "$WORK/fresh" > "$WORK/diff.txt"; then
  echo "MISMATCH for ${PROJECT}:build, see $WORK/diff.txt" >&2
  head -n 50 "$WORK/diff.txt" >&2
  exit 1
fi
echo "OK: cached and fresh outputs match for ${PROJECT}:build"

A scheduled CI script that restores one task's output from the remote cache, rebuilds it with --skip-nx-cache, strips workspace paths and ISO timestamps from both copies, and fails on any remaining difference.

When to avoid these controls, their limits and the alternatives

Not every repository needs the full set of controls. They matter most where untrusted code can reach a runner: public repositories, workflows that build fork pull requests, runner pools shared across teams, and anything whose build output ships to customers. A small private team with no external contributors, dedicated runners and internal-only artifacts carries less exposure. For that team, separating read and write credentials and keeping write access on main is often enough.

The controls do not stop a compromise inside the trusted pipeline. If a malicious dependency executes during a main-branch build, it writes poisoned output with legitimate credentials, and every later consumer treats that output as a valid hit. Dependency pinning, lockfile review and provenance checks still have to cover that path, because cache controls only decide who may write, not whether what they write is honest.

Alternatives

Approach Poisoning exposure Trade-off
No remote cache None from cache Every CI run rebuilds everything
Local-only caching Limited to one machine No sharing between developers or runners
Skip cache for release builds Releases protected, other builds exposed Release builds are slower
Separate read-only and read-write tiers Writes limited to trusted pipelines More credentials and backends to operate
Remote execution or signed artifacts Shifts trust to the executor or signer Higher setup cost and new infrastructure to secure

Remote execution moves the trust decision to an isolated build service. Artifact signing lets consumers verify who produced an output before they use it. Neither approach removes the need to secure the producer itself.

Impact on cost and engineering time, and incident response

Each control buys a smaller blast radius with compute or operational effort. Put both sides in the same budget.

Control Cost you pay What it buys
Read-only PR tokens PRs cannot reuse each other's results, so PR CI runs longer A fork or compromised PR job cannot plant an output
Main-only writes Long-lived branches that diverge from main get fewer cache hits Every cache entry comes from reviewed code
No cache for releases Rebuild time grows with how much of the graph the release touches Release artifacts never depend on cache contents
Verification rebuilds Extra compute for each sampled task Tampering is detected instead of shipped
Separate runners More pools, images and patching A PR job cannot reach a write token

Illustrative estimate (assumed numbers, not a benchmark). Extra runner-minutes per day = PRs per day × cacheable minutes per PR × fraction that would have hit another PR's writes. With 40 PRs, 15 minutes and 0.5, that is 40 × 15 × 0.5 = 300 runner-minutes per day.

The engineering time comes back elsewhere. Teams spend less time chasing false hits that pass in CI but fail locally. An exposure affects fewer systems. Audits go faster because you can name which pipeline was allowed to write.

Incident response checklist

  • Rotate or revoke the exposed token.
  • Invalidate or purge the remote cache. A cold rebuild for every branch is expensive, and that cost tempts teams to wait. Waiting keeps poisoned entries available to every consumer.
  • Rebuild the affected release artifacts from clean inputs, with the cache disabled.
  • Review every deployment that used cached outputs during the exposure window.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx remote cache security, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.