• next 16.2
  • sanity 5.31
  • react 19.2
  • 14 blocks
  • 4 build guards
  • drift: 0
  • €69 · one developer, forever

SecurityHumanist Studio

The prefix that ships your Sanity token to every visitor

NEXT_PUBLIC_ is not a naming convention. It is an instruction to compile the value into the browser bundle. Here is how a read token ended up in three client chunks, and the postbuild scan that would have caught it.

This repository once read its Sanity token from a variable called NEXT_PUBLIC_SANITY_API_READ_TOKEN. The name was chosen for consistency with the project ID and dataset beside it, both of which are genuinely public. The token was not, and the prefix did exactly what the prefix does.

What the prefix means

Next.js inlines every process.env.NEXT_PUBLIC_* reference at build time. Not at request time — the value is written into the JavaScript as a string literal, in every chunk that touches the module. If the module that reads it is imported by anything that reaches the client, the token is in the bundle. It looks identical in development, because there the value comes from the environment either way.

Here the chain was sanity/env.js → sanity/live.js → the root layout. The layout is a Server Component, but defineLive from next-sanity also hands a client component a browserToken for draft mode — and that import was enough to pull the module into the client graph. The token was a string literal in three chunks under .next/static. Verified with grep, which is the only tool that ever finds this.

Why nothing warns you

Because from the framework's point of view nothing is wrong. You asked for a public variable; you got a public variable. The mistake is in the name, and a name is not something a compiler can have an opinion about. Tree-shaking might save you — if the export happens to be unused it might be dropped — but a credential that is safe only because of an optimisation is not safe.

The split

The fix is structural, so that it cannot be undone by a rename. Public configuration and secrets live in different modules, and the secret one is marked so that importing it from the client is a build error rather than a leak:

sanity/env.server.jsjs
import "server-only";

// No NEXT_PUBLIC_ prefix, and importing this file from a client
// component fails the build instead of shipping the value.
export const readToken = process.env.SANITY_API_READ_TOKEN || undefined;

server-only is a package with no exports whose only job is to throw when it is bundled for the browser. It turns a naming discipline into a compile error.

The guard that would have caught it

A rename fixes this instance. A postbuild scan fixes the class:

scripts/check-secrets.jsjs
const PATTERNS = [
  { name: "Sanity API token", re: /\bsk[A-Za-z0-9._-]{40,}/ },
  { name: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/ },
  { name: "Private key", re: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/ },
];

for (const file of walk(".next/static")) {
  const source = fs.readFileSync(file, "utf8");
  for (const { name, re } of PATTERNS) {
    if (re.test(source)) hits.push({ file, name });
  }
}
if (hits.length) process.exit(1);

It walks the client output after every build and exits non-zero on anything shaped like a credential. It runs as postbuild, so it is not optional, and it prints a truncated preview rather than the value — a CI log is not a safe place for a token either.

Positive control, because a guard that has never fired is a guard you cannot trust: putting a public value through the same path proves the inlining is real and the scan reads the right files. Then a deliberately misnamed secret proves it fails. Both were done before the check was allowed to pass.

What to do if it already happened

Revoke first. Purging git history without rotating the credential is theatre — the value is in every clone, every fork and every CI cache that ever ran a build. Rotate, then rename, then add the scan so it is the last time.