Web Security
Web Security

Secrets & config: what must never reach the browser or git

Lesson · ~30 min
After this unit you can
  • Explain what NEXT_PUBLIC_ does — the value is inlined into client JavaScript at next build and 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 .env files out of git, and search a build's output for leaked secret values

Read

Simple
1 / 2

NEXT_PUBLIC_ means "copy this into every visitor's browser"

Flag

In 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_KEY and 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.

Medium
1 / 2

How a server-only secret still reaches the browser

Flag

An 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.

Complex
1 / 2

A committed secret stays leaked — rotate first

Flag

You 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:

  1. 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."
  2. Deploy the new value to every environment that used the old one.
  3. Check what the old key did while it was exposed: the provider's access logs, unexpected charges or API calls.
  4. 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-removal flag), force-push, and have every collaborator re-clone. The rewrite changes commit hashes, removes signatures and is irreversible, which is why it comes last.
  5. 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?

Flag

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?

Flag

What does import "server-only" at the top of lib/payments.ts do?

Flag

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?

Flag

Where should a missing required secret, such as the webhook signing key, be detected?

Flag

A live API key was pushed to a public repository yesterday. What do you do first?

Flag

A real secret shipped as NEXT_PUBLIC_… for a week. You rename the variable, remove the client reference and redeploy. Is the incident closed?

Flag

.env was committed months ago. Today you add .env to .gitignore and commit. What's still true?

Flag

Fill in the blank

Make it a build error to import this secret-handling module into client code.

Flag
import "";

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.

Flag
function 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.

Flag
grep -rl "sk_live_" .next/

.env is already tracked. Stop tracking it without deleting your local copy, so .gitignore can take effect.

Flag
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" }));
Flag
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);
Flag

Further reading