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 

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', }, }; 

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 

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 

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 

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 }; 

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.