Your remote build cache is one of the most important pieces of infrastructure you run, and likely one of the least audited. It is shared across branches and CI jobs in your organization. When a task gets a cache hit, the build task does not run. The cache hands back bytes that someone, somewhere, on some branch, produced earlier.
A shared cache is a trust boundary, and most teams have never treated it like one. Two things have to hold for a cached artifact to be trustworthy:
- The key that identifies it has to actually describe it.
- Only trusted builds can write under that key.
Break either one and a cache hit replays bytes that no one vetted.
We are in the middle of a steady stream of software supply-chain attacks built on compromised credentials and poisoned dependencies. A build cache is another target which affects everyone who pulls from it.
Cache misconfigurations are common and dangerous
A task's cache key is a hash of the inputs the tool accounts for: the files and values you declare, plus whatever it infers on your behalf. If the build reads a file that lands in neither bucket (a shared config outside the package, a source directory someone forgot to list), changing that file does not change the key. A poisoned artifact gets served under a clean hash, and there is no way to tell the difference.
The tool trusts the author to have enumerated everything and verified configuration correctness. One mistake can make the cache silently unsafe.
Undeclared inputs make the hash unreliable
Suppose a shop app declares inputs for its source code and Vite config.
{
"nx": {
"targets": {
"build": {
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"vite.config.ts"
]
}
}
}
}But the Vite config imports from a shared directory that sits outside of the project.
import { defineConfig } from "vite";import { sharedPlugin } from '../../shared/vite-shared-plugin.js';export default defineConfig({ plugins: [ sharedPlugin(), // ... ]});The build applies the plugin from shared/vite-shared-plugin.js which affects dist, yet the file is absent from the declared inputs:
Editing shared/vite-shared-plugin.js changes the output but not the cache key. A key that should identify the whole build now identifies a subset of it, so one hash can stand for two different artifacts. That is the gap every attack in this post walks through.
The exploit primitive
Every version of this attack reduces to the same three steps:
- Find a file the build reads and compiles into its cached output, but which is not in the task's hash (cache key).
- Show that editing that file produces the same cache key as before. Same key means the cache cannot distinguish a clean build from a poisoned one.
- On a shared cache, whoever populates that key first wins. A malicious branch builds with the edited file, seeds the key with a poisoned artifact, and everyone on clean
mainreplays it on a cache hit, without building, without ever seeing the payload in their working tree.
The victim never edited the malicious file. They never saw it in a diff. They did not even run the build. They pulled an artifact from the cache because the key matched. The consequence is direct: a teammate's branch can poison main.
This is not hypothetical
We took several widely-used open-source monorepos (real projects, millions of downstream installs between them) and confirmed the core flaw against the actual build tooling, not on paper. In many cases, editing a file that the build compiles into its output produced the same cache key.
Let's go over an issue we found when looking through projects. This kind of misconfiguration shows up in real open-source projects and affects many remote caching solutions, including both Nx and Turborepo.
Missing tsconfig in inputs list
When a tsconfig file is missing from a package's inputs it can lead to a broken cached output. In the best case, downstream consumers get a stale package. In the worst case, they get a compromised one.
A publishable package extends a tsconfig.base.json that lives at the repo root, outside the package:
{
"extends": "../../tsconfig.base.json"
}The root tsconfig.base.json is not in the package's declared inputs, and editing its paths mapping does not change the build's cache key. An attacker can remap any module to a file of their choosing.
{
"compilerOptions": {
"paths": {
"some-dep": ["./evil/shim.ts"]
}
}
}Here the some-dep module is swapped with an evil/shim.ts file that re-exports the real module and runs a payload. A bundler follows the remap and pulls evil/shim.ts into the output, so the compiled dist runs the malicious code instead of the real dependency. None of it changes the cache key, and the poisoned dist is what npm publish ships.
Now the consumers of this package receive the poisoned artifact.
You don't need an attacker
Take the malice out and the same broken key still costs you. If the key misses a file the build compiles, you get a false-positive hit, and the cache replays an artifact built before your change.
Say you update an API and rebuild. The changed file is not in the task's key, so it replays a stale artifact and everything downstream builds against the old shape. The mismatch either breaks in production, far from its cause, or runs stale, serving last week's behavior while your source says otherwise.
The key looked clean, so nothing points you at the cache. You lose hours bisecting correct code, and the usual escape is to bust the whole cache and rebuild, throwing away every legitimate hit and handing back the slow CI the cache existed to remove.
The cache is a trust boundary
None of these tools has a bug. They hash the inputs you declare and replay on a match, exactly as designed. The exposure is structural. A shared cache is a trust boundary with two halves. The key has to describe the artifact, and only trusted builds can write under it. Signing the artifact does not close the gap either: a signature proves who produced the bytes, not that the key covered every input that shaped them.
Mitigations you can apply today
Input enforcement is the durable fix, but you can shrink the blast radius right now, whatever build tool you use:
- Build release artifacts without the cache. Run publish jobs from a clean checkout with cache reads off, so a poisoned entry can never reach a tarball.
- Separate cache access by trust level. A protected branch should not read what an untrusted or fork branch could have written.
- Gate cache tokens to critical jobs. Treat cache tokens (
TURBO_TOKEN,NX_CLOUD_ACCESS_TOKEN) as sensitive information that potentially grants write access to your cache. Give untrusted and fork pipelines read-only access at most, for example by gating the write token behind a protected GitHub Actions environment. - Audit the usual undeclared inputs. Shared
tsconfigfiles and configs, env files, generated code, and global tool configs are the files most often read but never hashed. Automated analysis can help surface some of these.
These work with any build tool, but each is a manual control you have to set up and keep enforcing. With Nx Cloud, the recommended access-control settings give you the same protection without turning the cache off.
Nx Cloud separates write access by branch
Nx Cloud allows you to scope the cache by trust level. Builds on protected branches like main use a read-write token and read and write the shared, trusted cache. Builds on unprotected branches, like PRs, read it through a read-only token to stay fast, while their writes land in an isolated scope that protected branches never read. Workspace settings > Access Control > Use recommended settings sets this up, disabling anonymous access and provisioning the tokens so it does not depend on each pipeline author to get it right.
Make the inputs enforceable (Sandboxing)
Trust scoping controls what an untrusted build can write. Sandboxing controls what any write can contain, so it protects you even when you trust the writer. Nx Cloud Task Sandboxing runs each task with its file I/O validated against the inputs and outputs it declares, and records any read or write outside that set as a violation. The attack depends on a build quietly reading a file that is not in the hash, and sandboxing makes that impossible to do quietly.
Nx Cloud reports every violation, the unexpected reads and writes per task, so you see exactly what the build touches but does not declare.
In strict mode, sandboxing fails any task that reads or writes outside its declared set, closing both the deliberate poisoning and the everyday false-positive hit.
Secure your cache with Task SandboxingCatch undeclared reads before they can break your cache.
A cache only saves you time if you can trust what it gives back.
- Treat it as the trust boundary it is.
- Control who can write to it, and verify that what they wrote is what they declared.
Do both, and trust every cache hit.








