If a secret in your Next.js project has a NEXT_PUBLIC_ prefix, it is in the JavaScript you send to every visitor. Nothing warns you, and the site works normally. You can check right now. From your project root:
npm run build
grep -rEl 'sk[A-Za-z0-9._-]{40,}|AKIA[0-9A-Z]{16}|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY' .next/static
No output means none of those patterns turned up. A file name means you have something to look at. The patterns cover Sanity tokens, AWS keys and private keys, so for anything else, search for the first few characters of the real value.
We ran into this ourselves. A Next.js and Sanity project read its token from NEXT_PUBLIC_SANITY_API_READ_TOKEN, a name picked to match the project ID and dataset next to it. Those two are public. The token isn't, and it ended up in three files under .next/static.
Why this happens
Next.js replaces every process.env.NEXT_PUBLIC_* reference with its value at build time. The value is written into the JavaScript as a plain string, in every chunk that uses that module.
That makes the leak easy to miss, for two reasons:
- The variable can be used only on the server and still leak. In our case a Server Component imported the module, but a client component reached the same module through another import, and that pulled it into the browser bundle.
- From the framework's side nothing is wrong. You asked for a public variable and got one, so there is no warning to show.
Stop it at the source
Keep secrets in a separate file, without the prefix, and mark that file as server-only:
npm install server-only
// env.server.js
import "server-only";
export const readToken = process.env.SANITY_API_READ_TOKEN;
If a client component ever imports this file, even indirectly, the build fails with an error instead of shipping the value.
Add a check to every build
server-only protects the files you remembered to mark. A scan of the build output catches the rest. This script needs Node 20.12 or newer:
// scripts/check-secrets.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const PATTERNS = [
["Sanity token", /\bsk[A-Za-z0-9._-]{40,}/],
["Stripe live key", /\b[sr]k_live_[A-Za-z0-9]{20,}/],
["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
["Private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/],
];
const files = readdirSync(".next/static", { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name));
let found = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
for (const [name, pattern] of PATTERNS) {
const match = text.match(pattern);
if (match) {
found++;
// Print only the start: CI logs are not a safe place for a credential.
console.error(`${file}: ${name} (${match[0].slice(0, 6)}...)`);
}
}
}
if (found) process.exit(1);
console.log(`No credentials in ${files.length} client files.`);
Run it after each build by adding this to package.json:
"scripts": {
"postbuild": "node scripts/check-secrets.mjs"
}
npm run build now fails if a match turns up, so a deploy that runs that script stops too. Add a pattern for each provider you use.
Test it once before you rely on it. Put a fake value shaped like a token in a client component, build, and confirm the build fails.
If you found a real secret
- Revoke it first. Create a new one in the provider's dashboard and delete the old one. Treat the old value as public, because anyone who loaded your site could have read it.
-
Rename the variable so it has no
NEXT_PUBLIC_prefix, and update it in your hosting environment. - Redeploy, then run the grep again against the new build.
Cleaning your git history doesn't replace step 1. The value has already been served to browsers, and it may sit in forks and CI caches as well.
Full disclosure: we make Zero Drift, a paid Next.js and Sanity starter, and a version of this check ships with it. Everything above works without it.
Top comments (0)