Environment Variables and Secrets, Done Right
The NEXT_PUBLIC_ prefix that leaks a secret straight into the browser bundle, validating env vars at boot instead of request 47, and what to actually do when one gets committed.
Environment Variables and Secrets, Done Right
Every project ends up with a .env.local full of API keys, database URLs, and tokens within the first hour. Most of the ways this goes wrong are boring and completely preventable — which is exactly why they keep happening.
The NEXT_PUBLIC_ trap
In Next.js, any environment variable prefixed NEXT_PUBLIC_ gets inlined into the JavaScript bundle at build time and shipped to every visitor's browser. This is intentional and useful — it's how you get an analytics ID or a public API base URL into client components. It is also the single most common way a secret leaks.
# .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # WRONG — this is now public
STRIPE_SECRET_KEY=sk_live_... # correct — server-only
Ask an AI assistant to "add the Stripe key to the env file" without specifying which one, and there's a real chance it prefixes it — NEXT_PUBLIC_ looks like the generically-correct choice if the model is pattern-matching on "make this variable available," not reasoning about client/server trust boundaries. It will compile cleanly. It will work in the browser. Anyone with dev tools open can read it out of the page source.
The rule: a variable gets NEXT_PUBLIC_ only if you're comfortable with it appearing in "view source" on every page that uses it. Database URLs, API secrets, webhook signing secrets, and third-party secret keys never get the prefix. If you're not sure, don't prefix it — server code can always read an unprefixed var; client code can't read a prefixed one it doesn't need.
Which .env file is which
.env → defaults, safe to commit, no secrets
.env.local → your local overrides, gitignored, never committed
.env.production → production-only defaults (rarely used directly — most
platforms inject production vars through their dashboard)
.env.example → committed documentation: every key name, no real values
.env.local is gitignored by default in every Next.js starter — verify it, don't assume it (git check-ignore .env.local should print the filename). If a teammate or an AI assistant ever regenerates .gitignore from scratch, this is the line that's easiest to lose.
.env.example is documentation, not busywork
A repo with a .env.local.example (or .env.example) that lists every required key, with placeholder or dummy values, is the difference between "clone and run" and "clone, run, get a cryptic error, grep the codebase for process.env":
# .env.example — committed, no real secrets
DATABASE_URL=postgres://user:password@localhost:5432/dbname
STRIPE_SECRET_KEY=sk_test_...
WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
Keep it in sync manually — there's no tooling that reliably diffs "vars my code reads" against "vars this file documents," so treat adding a new process.env.X and updating .env.example as one change, not two.
Validate at boot, not at request 47
The default failure mode for a missing env var is silent: process.env.STRIPE_SECRET_KEY is undefined, it gets passed to a function expecting a string, and depending on what that function does, you get anything from a confusing runtime error to undefined quietly flowing through until it hits a user three steps later. The failure happens far from the cause.
Fail immediately, at startup, with a message that names the missing variable:
// lib/env.ts
const required = [
"DATABASE_URL",
"STRIPE_SECRET_KEY",
"WEBHOOK_SECRET",
] as const;
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
export const env = {
databaseUrl: process.env.DATABASE_URL!,
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.WEBHOOK_SECRET!,
};
Import env (not process.env) everywhere else in the codebase. Two things fall out of this for free: a typo'd variable name fails the build instead of failing silently in production, and every other file gets typed, autocompleted access instead of process.env.STIRPE_SECRET_KEY typos that TypeScript can't catch because process.env is typed as Record<string, string | undefined>.
If the project already has Zod, z.object({...}).parse(process.env) gets you the same result plus format validation (a DATABASE_URL that isn't a valid postgres connection string fails at boot instead of at the first query). Don't add Zod for this alone — the loop above is a dozen lines and covers the 90% case.
Never let the assistant echo secrets back
It's common, mid-debugging-session, to paste an error and have the assistant ask to see your env file, or to have it print console.log(process.env) to "check what's loaded." Both put your real secret values into the chat transcript and (if you're using a tool that logs conversations) into storage outside your control. Redact before you paste, and prefer logging which keys are present, never their values:
console.log("Loaded env keys:", Object.keys(process.env).filter(k => k.startsWith("MY_APP_")));
// never: console.log(process.env)
If a secret does get committed
It happens — a .env.local gets force-added, a key gets pasted directly into a config file instead of read from the environment. The moment you notice:
- Rotate it immediately, at the provider (Stripe, your database host, whoever issued it). This is not optional and it's not "later" — treat the key as compromised the second it hit a public or shared repo, full stop.
- Redeploy everywhere the old key was in use, with the new one.
- Removing the commit from git history does not undo the leak. If the repo was ever pushed to a public remote, or even a private one with more than a couple of collaborators, assume the value has already been seen or scraped. History rewriting (
git filter-repo, BFG) is worth doing for hygiene, but it is not the fix — rotation is the fix. - Check the provider's dashboard for any usage during the exposure window that you didn't initiate.
The rewrite-history step is the one people reach for first because it feels like undoing the mistake. It isn't — a key that was ever committed should be considered burned regardless of what the history looks like afterward.
Related: Security for vibecoded apps · Verifying webhooks safely · Deploying your project
Get the good stuff
New tools and posts, occasionally. No spam.