DEV Community

Cover image for How to Prevent Cache Poisoning in Nx and Lerna Monorepos
Renato Rodrigues
Renato Rodrigues

Posted on

How to Prevent Cache Poisoning in Nx and Lerna Monorepos

Remote caching is one of the most effective ways to speed up monorepo build pipelines in Nx and Lerna. By caching task results and build artifacts, developers and CI runners avoid re-running unchanged tasks.

However, sharing a single read/write token across every environment introduces a subtle but significant security threat: cache poisoning.

In this post, we'll look at the risk behind CVE-2025-36852 (CREEP) and show how read-only tokens mitigate it in Cacheiro, a self-hosted remote cache server for Nx and Lerna.


The Security Risk: Shared Access Tokens in CI/CD

CREEP is the reason Nx deprecated its official self-hosted cache packages in May 2026. The core problem: if anyone who can run a build can also write to the shared cache, a malicious build can plant artifacts that other developers and CI jobs will later trust.

In a typical team or open-source workflow, pull requests trigger automated tests and builds. If your remote cache relies on a single token for both reading and writing:

  1. A pull request introduces modified build logic or a malicious dependency.
  2. The CI pipeline runs the build and uploads the resulting artifact to the remote cache under the task hash.
  3. Nx task hashes are derived from task inputs, so an attacker can compute the hash that main will later compute for the same inputs and upload a crafted artifact under it. Cacheiro (like any content-addressed cache) refuses to overwrite an existing hash with a 409, which means the first write wins and a planted artifact stays in place until it expires.
  4. When main (or another developer) runs the same task, it downloads and uses the poisoned artifact straight from the remote cache.

The fix is to decouple read and write capabilities, so untrusted code can consume the cache but never populate it.


1. Preventing Cache Poisoning with Read-Only Tokens

Cacheiro supports a dedicated read-only token (available since cacheiro 1.3.0):

  • GET requests (downloading cached task outputs) are allowed.
  • PUT requests (uploading new task outputs) are rejected with 403 Forbidden.
  • A missing or unknown token gets 401 Unauthorized.

The read-only token has a few rules, all enforced at startup. It requires the regular auth.token to be set, it must differ from it, and it must not be empty. Otherwise the server refuses to start.

Server Configuration

With the Docker images, set both tokens through environment variables:

docker run -p 3000:3000 \
  -e CACHEIRO_AUTH_TOKEN="super-secret-write-token" \
  -e CACHEIRO_AUTH_READ_ONLY_TOKEN="read-only-token" \
  -v $(pwd)/cache:/cache \
  ghcr.io/rerodrigues/cacheiro-instants-fs
Enter fullscreen mode Exit fullscreen mode

When using Cacheiro as a library, set them in the auth object. Both are single strings:

import type { CacheiroConfig } from '@renatorodrigues/cacheiro';

const config: CacheiroConfig = {
  server: { port: 3000, host: '0.0.0.0' },
  auth: {
    token: 'super-secret-write-token',
    readOnlyToken: 'read-only-token',
  },
};
Enter fullscreen mode Exit fullscreen mode

Client Configuration

Nx and Lerna send whatever token is set in NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN, and find the server through NX_SELF_HOSTED_REMOTE_CACHE_SERVER. Give the write token only to trusted pipelines (main branch, releases) and the read-only token to untrusted ones (pull requests).

Trusted workflow, can read and write:

# .github/workflows/main.yml
- name: Build
  env:
    NX_SELF_HOSTED_REMOTE_CACHE_SERVER: ${{ vars.CACHEIRO_URL }}
    NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${{ secrets.CACHEIRO_AUTH_TOKEN }}
  run: npx nx run-many --target=build
Enter fullscreen mode Exit fullscreen mode

Pull request workflow, can only read:

# .github/workflows/pr-check.yml
- name: Build
  env:
    NX_SELF_HOSTED_REMOTE_CACHE_SERVER: ${{ vars.CACHEIRO_URL }}
    NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${{ secrets.CACHEIRO_READ_ONLY_TOKEN }}
  run: npx nx run-many --target=build
Enter fullscreen mode Exit fullscreen mode

Unmerged or untrusted code can now read existing cache entries to save time, but can never inject new artifacts into the shared cache.

Keep the write token out of reach

A read-only token only helps if untrusted jobs never see the write token:

  • Store the write token only in secrets that PR workflows cannot access, and keep it out of any workflow that runs code from forks.
  • Be careful with pull_request_target and similar triggers that run with repository secrets.
  • Serve Cacheiro over HTTPS when it is reachable from outside a private network. Set CACHEIRO_TLS_CERT_FILE and CACHEIRO_TLS_KEY_FILE, or server.tls in library config.

2. Frictionless Local Development (Optional Auth)

Strong authentication is recommended for shared pipelines, but forcing token setup on an isolated local environment adds friction. In Cacheiro, authentication is optional (since cacheiro 1.4.0).

With the Docker images, leave out CACHEIRO_AUTH_TOKEN:

docker run -p 3000:3000 \
  -v $(pwd)/cache:/cache \
  ghcr.io/rerodrigues/cacheiro-instants-fs
Enter fullscreen mode Exit fullscreen mode

With the library, leave out the auth object:

const config: CacheiroConfig = {
  server: { port: 3000, host: '127.0.0.1' },
  // no `auth`: requests are accepted without a token
};
Enter fullscreen mode Exit fullscreen mode

A few details worth knowing:

  • Setting auth.readOnlyToken without auth.token is an error, so you cannot end up with a read-only token on an open server.
  • The reference runner (cacheiro-runner) ships with a default auth.token of change-me, so omitting the variable there does not turn auth off. Change the token instead.
  • Passing an empty string as the token to disable auth still works but is deprecated and will be rejected in the next major version. Omit the field instead.
  • /health never requires a token.

⚠️ Note: Only run without authentication on localhost or inside an isolated, private network.


Check Out the Project

Cacheiro is a self-hosted, modular remote cache server for Nx and Lerna monorepos, with filesystem, S3, GCS and Azure Blob stores.

Have you put cache poisoning protections in place in your monorepo pipelines? Let's discuss in the comments.

Top comments (2)

Collapse
 
_5c75b1d3a1b3628dec81_58 profile image
Yoshiyuku •

Nice writeup — decoupling read/write access is the right first move, and the “write token never reaches PR workflows” caveat you call out is the part people actually get wrong in practice (that pull_request_target footgun especially).

One thing worth adding on top of this, since it’s a different layer of defense: the cache is keyed by a hash of task inputs, not a hash of the artifact content itself. That means even with write access fully locked down to trusted pipelines, there’s no cryptographic guarantee that what a client downloads under a given key is actually what was uploaded — nothing stops silent corruption or tampering between upload and download (compromised storage backend, misconfigured bucket permissions, a bug in the store layer, etc.) from going undetected, since the client has no independent way to verify integrity.

The fix is cheap: have the server compute and store a SHA-256 digest of the artifact alongside it at upload time, return it on download (e.g. as a header), and have the client verify the downloaded bytes hash to that digest before trusting them. That’s a standard content-addressable-storage pattern (same idea as npm’s integrity hashes or Docker image digests) — access control (who can write) and integrity verification (is what I downloaded actually what was written) are two separate guarantees, and right now this setup only has the first one.

Would be curious whether Cacheiro already does this at the storage-backend level (e.g. S3 ETags) even if it’s not exposed/verified client-side.

Collapse
 
officialmailkr profile image
오피셜메일 •

PR 빌드에는 읽기 전용 토큰만 주고 main·릴리스 파이프라인에만 쓰기 권한을 남긴 구분이 명확합니다. 설명에 나온 401/403 동작을 CI에서 자동으로 검증하면 설정이 바뀌었을 때도 경계를 지킬 수 있겠네요. 예를 들어 포크 PR 실행 컨텍스트에서 캐시 GET은 허용되고 PUT은 반드시 403인지, 쓰기 토큰이 환경 변수로 주입되지 않았는지를 별도 테스트로 확인하는 방식이 떠오릅니다.