Where secrets live and how they rotate
Five rules for where a secret may live and how to rotate it without an outage, enforced by the .env.example contract and a Gitleaks pre-commit guard.
By now your app holds about a dozen live secrets: a DATABASE_URL, a STRIPE_SECRET_KEY, a BETTER_AUTH_SECRET, a RESEND_API_KEY, an UPSTASH_REDIS_REST_TOKEN, a SENTRY_AUTH_TOKEN. Each is a key to money, customer data, or someone’s identity, and one leak ruins your week.
The question is not how to store a secret; you already keep them in environment variables. It is where each secret is allowed to exist, and what happens the day a developer with production access leaves.
A secret is not a config value but a liability with a lifetime: it is created, used, and eventually retired. We follow one secret through that life, where each stage is a rule tied to the bug it prevents. Most of the pieces are already in place. This lesson adds two: a .env.example file that keeps onboarding from silently breaking, and a pre-commit scanner that stops a secret before it reaches a commit.
Created
provider dashboard
Declared
env.ts schema
Stored
platform secret store
Scoped
local / preview / prod
Guarded
commit-time scan
Rotated
on an event
Retired
revoked at provider
Rule 1: a secret never lives in source code
Section titled “Rule 1: a secret never lives in source code”Here’s the bug. Under deadline pressure, someone writes this:
const STRIPE_SECRET_KEY = 'sk_live_EXAMPLE_NOT_REAL';const stripe = new Stripe(STRIPE_SECRET_KEY);It works. The app boots, payments go through, and the pull request is approved because the diff looks fine.
That is why this rule is absolute. Delete the line in your next commit and git still keeps the secret: in git log, in every clone and fork, and in the CI cache. A secret committed once is committed for good. The only fix is to rotate it (rule 5) and scrub history with a tool like BFG Repo-Cleaner.
Instead, the only shape a secret may take in your code is the typed import you already set up:
import { env } from '@/env';const stripe = new Stripe(env.STRIPE_SECRET_KEY);No literal, no process.env, no fallback default. You read env.STRIPE_SECRET_KEY and trust the build to fail loudly when it isn’t set: a missing secret should stop the deploy, not boot a half-configured app that breaks on the first customer request. The next lesson shows how the Zod schema behind env enforces that.
Scan your own repo now: grep for sk_live, sk-, and any long random-looking string literal. Those random strings are a high-entropy string , and a scanner spots them by their randomness rather than by recognizing the provider. We automate this search at the end of the lesson.
Rule 2: a secret never reaches the browser bundle
Section titled “Rule 2: a secret never reaches the browser bundle”This is the most dangerous mistake because the failure is invisible. The code compiles, the app runs, every test passes, and your secret sits in plain text inside the JavaScript shipped to the browser, where anyone with devtools reads it in seconds.
Here is the mechanism. The browser has no environment variables, so when Next.js bundles a Client Component it inlines any process.env.SOMETHING reference as a literal value into the output. A secret read in the wrong place isn’t fetched at runtime, it’s baked into a file every visitor downloads. The build never warns you, because as far as the bundler knows, you meant it.
The two components below behave identically to the user, but one has handed your Stripe secret to the public.
'use client';export function CheckoutButton() { const onClick = () => fetch('/charge', { headers: { authorization: process.env.STRIPE_SECRET_KEY! } }); return <button onClick={onClick}>Pay</button>;}The key ships to the browser. This 'use client' file runs client-side, so the bundler inlines STRIPE_SECRET_KEY as a literal into the page’s JavaScript. Open devtools, then Network, then the JS bundle, and there’s your live key.
'use client';export function CheckoutButton() { const onClick = () => startCheckout(); // startCheckout is a Server Action return <button onClick={onClick}>Pay</button>;}The key never leaves the server. The client only triggers a Server Action that reads the Stripe secret; the browser receives the redirect URL, never the key.
But “remember to put it on the server” is a hope, not a defense; the goal is to make the leak impossible, not unlikely. You have two structural guards. The first you already built: importing a server variable from the typed env object into a Client Component fails the build. The second is one line at the top of any server-only module:
import 'server-only'; // build error if this module is ever imported client-sideAny client module that imports a file starting with import 'server-only' breaks the build. Both guards turn the mistake into a failed deploy instead of a production discovery.
The NEXT_PUBLIC_ trap
Section titled “The NEXT_PUBLIC_ trap”There is exactly one sanctioned channel to the browser: prefix a variable with NEXT_PUBLIC_, and Next.js inlines it into the client bundle by design. The prefix promises the value is safe for the whole world to read, so the name can lie. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is correct, because a publishable key belongs in the browser. NEXT_PUBLIC_STRIPE_SECRET_KEY is a real breach: the prefix wins over the name, so the key ships to every visitor.
Grep your repo for any NEXT_PUBLIC_* variable whose name contains SECRET, TOKEN, or KEY without a reason to be public. A publishable key and a DSN have that reason; a secret never does. For each variable below, decide whether it is safe to ship with the NEXT_PUBLIC_ prefix or must stay server-only.
Decide whether each variable may carry the NEXT_PUBLIC_ prefix and ship to the browser, or must stay server-only. Judge by authority, not by the vendor that issued it. Drag each item into the bucket it belongs to, then press Check.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYNEXT_PUBLIC_POSTHOG_KEYNEXT_PUBLIC_SENTRY_DSNNEXT_PUBLIC_APP_URLSTRIPE_SECRET_KEYDATABASE_URLBETTER_AUTH_SECRETRESEND_API_KEYUPSTASH_REDIS_REST_TOKENSENTRY_AUTH_TOKENIf the Sentry pair tripped you up, that is the point: a DSN is fine in the browser, while the SENTRY_AUTH_TOKEN beside it can upload source maps and read your data, so it stays server-only. Same vendor, opposite buckets.
Rule 3: secrets live in the platform’s secret store, marked sensitive
Section titled “Rule 3: secrets live in the platform’s secret store, marked sensitive”If they aren’t in source and aren’t in the bundle, your production secrets live in your deployment platform’s encrypted secret store: for this stack, Vercel’s project environment variables. You add each secret, scope it to the environments that need it, and Vercel injects it at build and runtime.
The detail to get right is the sensitive flag. A normal Vercel variable can be read back through the dashboard or the CLI; a variable marked sensitive is write-only once created. You can overwrite it, but no one, not a teammate, not a compromised CI token, can read its value out again, so a leaked dashboard session or stolen API token can’t exfiltrate it.
vercel env add now defaults to sensitive for production and preview, so secrets you add through the CLI are already correct. It stays an audit item because two kinds of non-sensitive secret slip past that default. The flag isn’t retroactive, so variables created before the default existed are still readable. And third-party integrations don’t always set it: the Vercel Sentry integration ships SENTRY_AUTH_TOKEN as a plain, readable variable. So audit what the integrations wrote, not just what you add.
Write-only has a cost: if you forget a secret’s value, Vercel can only let you overwrite it, never read it back. Keep a break-glass copy somewhere you control, like your password manager, not Slack and not a ticket.
Local development is the other place secrets live, and it’s simpler: put them in .env.local, which is gitignored, sourcing the values from your password manager. Confirm the gitignore line is there:
.env.local.env*.localOnce copy-pasting .env.local files around stops scaling, a dedicated secrets manager like Doppler or Infisical syncs every secret to every environment from one source. Until then, Vercel’s store plus a password manager is enough.
Rule 4: three environments, three separate sets of secrets
Section titled “Rule 4: three environments, three separate sets of secrets”The same secret name takes a different value in each environment. Your local machine, Vercel’s preview deployments, and production each get their own keys, and production keys appear only in production.
You already do this for most services: Stripe gives you live and test keys, Resend has production and sandbox. Extend it everywhere, with a separate Upstash instance and a separate Postgres database per environment, so nothing you do in preview or on your laptop can touch real customer rows.
This prevents one of the most common ways companies leak production: reusing a production secret in preview or local. A preview deployment is the soft underbelly. There are dozens of them, they’re short-lived, and they get shared in PR comments and screenshots. If one holds the production database URL, a single leaked link is a production breach. Keep production keys out of preview and that entire class of accident disappears.
Here are the same services, shown per environment:
| Local | Preview | Production | |
|---|---|---|---|
| Stripe | test key | test key | live key |
| Resend | sandbox | sandbox | production |
| Postgres | local / branch DB | preview branch DB | production DB |
| Upstash | dev instance | preview instance | production instance |
The .env.example contract
Section titled “The .env.example contract”A developer who just cloned the repo has none of your secrets and no list of what the app needs. That is what .env.example is for: a file checked into git that lists every variable name, each with a placeholder value and a comment pointing at where to get the real one. A newcomer, or an AI agent setting up the project, copies it to .env.local and fills in the blanks.
env.ts and .env.example must list the exact same variables. When they drift, onboarding fails silently: the app boots fine, then crashes on the first request that touches the variable nobody knew to set. The next lesson adds an automated check that keeps the two in lockstep.
Here’s a representative .env.example. The conventions matter more than the values.
# --- Database ---# source: Neon dashboard → Connection stringDATABASE_URL="postgres://user:password@host/db"
# --- Auth ---# source: openssl rand -base64 32BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---# source: Stripe dashboard → Developers → API keysSTRIPE_SECRET_KEY="sk_test_replace_me"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---# source: Resend dashboard → API KeysRESEND_API_KEY="re_replace_me"Every value is a placeholder, never a real secret. .env.example is committed, so a real value here is the exact leak this lesson is about. The shape, such as the sk_test_ or re_ prefix, hints at what’s expected without being live.
# --- Database ---# source: Neon dashboard → Connection stringDATABASE_URL="postgres://user:password@host/db"
# --- Auth ---# source: openssl rand -base64 32BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---# source: Stripe dashboard → Developers → API keysSTRIPE_SECRET_KEY="sk_test_replace_me"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---# source: Resend dashboard → API KeysRESEND_API_KEY="re_replace_me"Each variable carries a # source: comment pointing at the provider dashboard or the command that generates it, turning the blanks into a checklist.
# --- Database ---# source: Neon dashboard → Connection stringDATABASE_URL="postgres://user:password@host/db"
# --- Auth ---# source: openssl rand -base64 32BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---# source: Stripe dashboard → Developers → API keysSTRIPE_SECRET_KEY="sk_test_replace_me"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---# source: Resend dashboard → API KeysRESEND_API_KEY="re_replace_me"Public variables sit alongside the rest and stay visibly prefixed, so the contract shows at a glance which values are client-bound and which are server-only.
# --- Database ---# source: Neon dashboard → Connection stringDATABASE_URL="postgres://user:password@host/db"
# --- Auth ---# source: openssl rand -base64 32BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---# source: Stripe dashboard → Developers → API keysSTRIPE_SECRET_KEY="sk_test_replace_me"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---# source: Resend dashboard → API KeysRESEND_API_KEY="re_replace_me"Group by service: database, auth, billing, email. A newcomer fills it in provider by provider, and a missing group is obvious.
Rule 5: rotate on events, deploy before you revoke
Section titled “Rule 5: rotate on events, deploy before you revoke”A developer with production access just left the company, so now what? You rotate every secret they could have seen. This is the one stage that’s a procedure, not a setting.
Rotation triggers on an event: someone offboarding, a suspected leak, a vendor forcing a reset, not a date on the calendar. Rotating everything every 90 days regardless is mostly theater: it spends real effort on a schedule unrelated to real risk, and the busywork makes people sloppy when a real event hits.
What turns a routine rotation into an outage is order. The live key exists in two places at once: at the provider (Stripe, Resend) and in Vercel. Update Vercel first and confirm it’s live, then revoke the old key at the provider.
Provider
Vercel
Provider
Vercel
Provider
Vercel
Provider
Vercel
Provider
Vercel
The middle of that diagram is the point: for a beat, both keys are valid. That overlap keeps a working key live at every instant. Reverse the order and you delete it.
Make this repeatable with a runbook : a doc in the repo listing each secret, its provider, the steps to rotate it, and where it lives in Vercel. You follow it instead of reconstructing the procedure from memory, and it becomes part of the deliverable at the end.
Block secrets at the commit with a pre-commit scan
Section titled “Block secrets at the commit with a pre-commit scan”Git history is forever (rule 1), so the cheapest place to stop a secret is before it becomes a commit: after that you’re rotating keys and scrubbing history, before it you just fix the line. So scan staged changes at the commit boundary and block the commit when a secret turns up.
The guard is a pre-commit hook . The scanner is Gitleaks, which reads your staged diff and flags both known secret patterns (a sk_live_…, an AWS key) and high-entropy strings. Manage the hook with Husky : a hook that lives only on your laptop protects only you.
Set it up:
-
Add Husky as a dev dependency.
Terminal window pnpm add -D husky -
Scaffold the hooks directory. This creates
.husky/and adds apreparescript topackage.json, so the hook auto-installs whenever a teammate runspnpm install. A hand-written.git/hooksscript isn’t committed, so it never reaches anyone else.Terminal window pnpm dlx husky init -
Replace the contents of
.husky/pre-commitwith the Gitleaks scan (below). Install Gitleaks via your system package manager (e.g.brew install gitleaks) so it’s on PATH.
The hook is one line:
gitleaks protect --stagedprotect --staged scans exactly what git diff --cached shows, so it runs in well under a second on every commit. On a match it exits non-zero, Husky aborts the commit, and you fix the line before anything enters history.
The four-check leak audit
Section titled “The four-check leak audit”Four checks turn the rules above into a procedure you can run against any repo today. Each is a grep or a tool tied to one rule, and every hit is a finding to fix.
process.env.X outside env.ts. Grep process.env; every hit outside env.ts bypasses type safety and the server/client split. The one allowed exception is process.env.NODE_ENV.NEXT_PUBLIC_* matching a secret pattern. Grep NEXT_PUBLIC_ for any name containing SECRET, TOKEN, or KEY without a publishable justification; each is a value shipped to the browser by mistake..env* file in git log. Scan the history for committed env files. If a real secret turns up, rotate it and purge it with BFG Repo-Cleaner, since deleting the file in a new commit leaves it in history.One boundary: dedicated key-management systems (KMS, HSM, Vault) are out of scope before a Series A. Reach for one only when you encrypt customer data at rest or a compliance team hands you a key-custody requirement.
External resources
Section titled “External resources”The canonical sources for the pieces this lesson wired.
Vercel's docs on the write-only sensitive flag and how it's set.
The secret scanner behind the pre-commit hook, with its rule reference.
Next.js docs on the NEXT_PUBLIC_ prefix and how values get inlined into the browser bundle.
The fast tool for scrubbing a leaked secret out of every commit in git history.