Secrets & config: what must never reach the browser or git
- Explain what
NEXT_PUBLIC_does — the value is inlined into client JavaScript atnext buildand frozen — and treat every prefixed variable as public - Keep server secrets on the server: unprefixed variables,
import "server-only", and minimal props and Server Action return values - Spot configuration code that fails open when a secret is missing, and make it fail closed at startup
- Respond to a committed or published secret in the right order: revoke and rotate first, history second
- Keep
.envfiles out of git, and search a build's output for leaked secret values
Read
NEXT_PUBLIC_ means "copy this into every visitor's browser"
FlagIn Next.js, an environment variable is server-only unless its name starts with
NEXT_PUBLIC_. The prefix isn't a label; it's an instruction to the build:
# .env.local
NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk
PAYMENTS_SECRET_KEY=sk_live_…
"use client";
setupAnalytics(process.env.NEXT_PUBLIC_ANALYTICS_ID);
The Next.js docs: it will "replace all references to process.env.NEXT_PUBLIC_ANALYTICS_ID …
with the value from the environment in which you run next build", and the value "will be inlined
into any JavaScript sent to the browser". After the build, that line in the shipped bundle reads
setupAnalytics("abcdefghijk"). Anyone can open DevTools and read it.
Two consequences:
- The prefix decides, not the variable's purpose. Name a secret
NEXT_PUBLIC_PAYMENTS_SECRET_KEYand it is published, whatever you intended. Third-party services usually give you two keys for this reason: a publishable one that's designed to be seen in the browser, and a secret one that must never leave the server. Only the publishable one may ever carry the prefix. - The value is frozen at build time. Changing a
NEXT_PUBLIC_variable in production does nothing until you rebuild. One Docker image promoted from staging to production carries staging's public values.
An unprefixed variable such as PAYMENTS_SECRET_KEY is only available in the Node.js environment;
its value never reaches the client bundle. That is the default to rely on, and the rest of this
unit is about the ways a server-only value still escapes.
How a server-only secret still reaches the browser
FlagAn unprefixed variable isn't inlined, but in Next.js 16 the server and the client share code and pass data to each other. Three ways a secret crosses that boundary anyway:
1. A server module imported into client code. Modules can be imported from both sides, so a helper written for the server can end up in a Client Component's import graph:
// lib/payments.ts
import "server-only";
export async function charge(amount: number) {
return fetch("https://api.payments.example/charges", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAYMENTS_SECRET_KEY}` },
body: JSON.stringify({ amount }),
});
}
Without the first line, importing charge into a "use client" file compiles; the secret isn't
inlined, so the call just fails in the browser. That's a confusing bug, and it's also server code
you never meant to ship. With import "server-only", the docs promise "a build-time error" the
moment the module is imported into the client environment. Mark every module that touches
secrets, the database or internal APIs this way.
2. Props from a Server Component to a Client Component. Everything passed as a prop is serialised into the page for the client:
const user = await db.user.findUnique({ where: { id } }); // includes apiToken, passwordHash…
return <Profile user={user} />; // all of it is sent to the browser
The Next.js data security guide calls this out: pass only the fields the component renders (a small DTO), not the database row.
3. Server Action return values. "Server Action return values are serialized and sent to the
client." Returning db.user.update(...) sends the whole updated record back, internal fields
included. Return what the UI needs: { success: true }.
The guide's structural fix is a Data Access Layer: one server-only module that reads
process.env, talks to the database, checks authorisation and returns minimal objects. Everything
else imports from it. In its words: "only the Data Access Layer should access process.env."
When you audit, process.env outside that layer, and broad prop types on "use client"
components, are what to look for.
A committed secret stays leaked — rotate first
FlagYou notice PAYMENTS_SECRET_KEY=sk_live_… in a commit from last week, already pushed. The
instinct is to delete the line and push. That fixes the file, not the leak.
Why deleting isn't enough. The value is in the commit that added it. GitHub's guide on removing sensitive data lists where it stays reachable even after you rewrite history and force-push: clones and forks, "cached views on GitHub" by commit SHA, and pull requests that reference it. You can't reach into other people's clones. Anyone (or any bot scanning public pushes) who saw it has it.
So the order is:
- Revoke or rotate the credential, first and immediately. GitHub's guide: "as a first step you need to revoke and/or rotate that secret", and once that's done, the old value is worthless and history rewriting "may not be warranted". OWASP: "Keys that were exposed should undergo immediate revocation." Restarting your app changes nothing; OWASP: "Restarting the application does not itself revoke stolen credentials."
- Deploy the new value to every environment that used the old one.
- Check what the old key did while it was exposed: the provider's access logs, unexpected charges or API calls.
- Then decide about history. If the repository is public, or the value can't be rotated,
rewrite it with
git filter-repo(GitHub's guide wants version 2.47 or later, for the--sensitive-data-removalflag), force-push, and have every collaborator re-clone. The rewrite changes commit hashes, removes signatures and is irreversible, which is why it comes last. - Stop the next one: secret scanning, a pre-commit hook, GitHub push protection.
NEXT_PUBLIC_ leaks are the same, only wider. If a secret was ever built with the prefix,
it's in every JavaScript chunk that referenced it: on your CDN, in visitors' caches, in any copy
someone saved. Removing the prefix and redeploying stops new copies; only rotation makes the old
ones useless.
The same holds for secrets in logs, error-tracker breadcrumbs and screenshots in tickets: once a value has been somewhere you don't control, treat it as public and rotate it.
Quiz
Which statement about a Next.js NEXT_PUBLIC_ variable is accurate?
You build one Docker image in staging and promote the same image to production with production's environment variables. The browser keeps calling the staging API. Why?
FlagWhat does import "server-only" at the top of lib/payments.ts do?
A Server Component loads const user = await db.user.findUnique({ where: { id } }) and renders <Profile user={user} />. Profile is a Client Component that displays only user.name. What reaches the browser?
Where should a missing required secret, such as the webhook signing key, be detected?
FlagA live API key was pushed to a public repository yesterday. What do you do first?
FlagA real secret shipped as NEXT_PUBLIC_… for a week. You rename the variable, remove the client reference and redeploy. Is the incident closed?
.env was committed months ago. Today you add .env to .gitignore and commit. What's still true?
Fill in the blank
Make it a build error to import this secret-handling module into client code.
Flagimport ""; export async function charge(amount: number) { /* uses process.env.PAYMENTS_SECRET_KEY */ }
Fail closed: a missing or empty required variable must stop the server at startup.
Flagfunction required(name: string): string { const value = process.env[name]; if (!value) new Error(`Missing required environment variable: ${name}`); return value; }
After next build, search the JavaScript that will be served to browsers for a known secret prefix.
grep -rl "sk_live_" .next/.env is already tracked. Stop tracking it without deleting your local copy, so .gitignore can take effect.
git rm .env
Predict the output
// API_KEY was never set on this server
const API_KEY = process.env.API_KEY;
function isAuthorized(headers) {
return headers["x-api-key"] === API_KEY;
}
console.log(isAuthorized({}));
console.log(isAuthorized({ "x-api-key": "guess" }));import { createHmac } from "node:crypto";
// WEBHOOK_SECRET is not set; `?? ""` was added to stop a startup crash
const secret = process.env.WEBHOOK_SECRET ?? "";
const sign = (body) => createHmac("sha256", secret).update(body).digest("hex");
const forged = createHmac("sha256", "").update('{"paid":true}').digest("hex");
console.log(sign('{"paid":true}') === forged);Further reading
- Next.js docs: Environment variables
- Next.js docs: Server and Client Components (preventing environment poisoning)
- Next.js docs: Data Security
- Next.js docs: output (the .next/static folder)
- Next.js docs: instrumentation.js (register runs before the server handles requests)
- OWASP Cheat Sheet: Secrets Management
- GitHub Docs: Removing sensitive data from a repository
- Git docs: gitignore
- OWASP Top 10:2025 — A10 Mishandling of Exceptional Conditions