Where secrets live and how they rotate
Where every secret your SaaS holds is allowed to live, how it stays out of source and the browser, and how you rotate it without an outage, plus a .env.example contract and a Gitleaks pre-commit guard.
Take a moment to count. By this point your app holds a DATABASE_URL, a STRIPE_SECRET_KEY, a BETTER_AUTH_SECRET, a RESEND_API_KEY, an UPSTASH_REDIS_REST_TOKEN, and a SENTRY_AUTH_TOKEN: roughly a dozen live secrets. Each one is a key to money, customer data, or someone’s identity, and any single one of them leaking is a bad week.
So the question this lesson answers isn’t “how do I store a secret.” You already store them in environment variables. It’s the question a junior never thinks to ask and an experienced engineer can’t stop thinking about: where is each secret allowed to exist, and what happens to it the day a developer with production access walks out the door?
The reframe that makes the rest of this lesson click is that a secret is not a config value. It’s a liability with a lifetime: it gets created, it gets used, and eventually it has to be retired. Every rule below exists to shrink the damage one leaked secret can do at some point in that life. We’ll follow one secret through its whole life, where each stage is a rule tied to the one concrete bug it prevents.
You already have the tools for most of what follows: the typed env object from when you set up build-time env validation, and the secrets you’ve collected across the course. This lesson names the rules and wires up two things that aren’t in code yet: a .env.example file that keeps onboarding from silently breaking, and a pre-commit scanner that stops a secret before it can ever reach a commit.
Here is the whole life of a secret. Keep this picture in mind, because every rule below lives at one of these stages.
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. Somewhere in a .ts file, 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 gets approved because the diff looks fine. Now that key lives in git log forever.
That last part is why this rule is absolute. You can delete the line in the very next commit and feel like you fixed it, but git keeps history. The secret is still sitting in git log, in every clone every teammate has, in every fork, and in the CI cache. A secret committed once is committed for good. The only real fix is to rotate it (rule 5) and scrub history with a tool like BFG Repo-Cleaner, which is a long afternoon you would rather never spend.
Build this habit 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 if it isn’t set. That last point is the experienced-engineer posture: a missing secret should stop the deploy, not boot a half-configured app that fails on the first customer request. Why the build fails, meaning the Zod schema behind env and the rules it enforces, is the next lesson’s job. Here it’s just a tool you already own.
You can run a rough scan yourself in your own repo right now: grep for sk_live, sk-, and any long random-looking string literal. Those random-looking strings have a name. They’re a high-entropy string , and a scanner spots them by their randomness rather than by recognizing the provider. We’ll automate exactly 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 rule a junior gets wrong most reliably, and the most dangerous, because the failure is invisible. The code compiles, the app runs, and every test passes. Meanwhile the secret is sitting in plain text inside the JavaScript your server shipped to the browser, where anyone with devtools can read it in about ten seconds.
The mechanism is worth slowing down for. Next.js renders some of your components in the browser: the ones marked 'use client'. When you bundle a Client Component, the bundler reads any process.env.SOMETHING references and inlines the literal value into the output JavaScript. It has to, because the browser has no environment variables. So a secret read from the wrong place doesn’t get fetched at runtime, it gets baked into a file that ships to every visitor. The build does not warn you, because as far as the bundler knows, you meant to do that.
Look at the difference. The two components below behave identically from the user’s point of view, but one of them 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. The build said nothing.
'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 component only triggers a Server Action. The Stripe secret is read inside that action, on the server, and the browser receives the redirect URL, never the key. The boundary is the defense.
The “Safe” variant shows the general shape: secrets are read on the server, and the client receives the result of using them, never the key itself. But “remember to put it on the server” is not a defense, it’s a hope. Experienced engineers don’t rely on remembering; they make the wrong thing impossible.
You have two structural guards for exactly this. You already set up the first: the typed env object splits variables into a server group and a client group, and importing a server variable into a Client Component is a build-time error. This is the one place the build does catch the leak for you, because the schema turns the mistake into a failed deploy. The second guard is one line you put at the top of any server-only module:
import 'server-only'; // build error if this module is ever imported client-sideIf any client module ever imports a file that starts with import 'server-only', the build breaks. Both guards follow the same principle: make the wrong thing fail at build time rather than be discoverable in production.
The NEXT_PUBLIC_ trap
Section titled “The NEXT_PUBLIC_ trap”There’s exactly one sanctioned channel for sending a value to the browser: prefix its name with NEXT_PUBLIC_. Anything starting with NEXT_PUBLIC_ gets inlined into the client bundle by design. That prefix is a promise: it says “this value is safe for the whole world to read.”
Which means the name itself can lie. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is correct, because a publishable key belongs in the browser. But NEXT_PUBLIC_STRIPE_SECRET_KEY is a contradiction in terms and a genuine breach: the prefix promises “public” while the name says “secret,” and the prefix wins. The variable gets shipped to every visitor.
So here is the grep target for your own repo: any NEXT_PUBLIC_* variable whose name contains SECRET, TOKEN, or KEY without a legitimate reason to be public. A publishable key has that reason, and a DSN has that reason. A secret never does.
The line between the two is the single most useful thing to take from this rule, so sort it out by hand. For each variable below, decide whether it’s safe to expose with the NEXT_PUBLIC_ prefix or must stay server-only.
Decide, for each variable, whether it may safely carry the NEXT_PUBLIC_ prefix and ship to the browser — or whether it must stay server-only. Judge by authority, not by which vendor 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’s the point landing: a DSN is fine in the browser. The SENTRY_AUTH_TOKEN next to it can upload source maps and read your data, so it never leaves the server. Same vendor, opposite buckets: judge by authority, not by which company issued it.
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”Your secrets aren’t in source and aren’t in the bundle, so where do the production ones live? In your deployment platform’s encrypted secret store. For this stack that’s Vercel’s project environment variables. You add each secret there, scoped to the environments that need it, and Vercel injects it at build and runtime.
Here is the detail an experienced engineer checks and a junior skips: the sensitive flag on every secret. A normal environment variable on Vercel can be read back, through the dashboard or the CLI. A variable marked sensitive is write-only after you create it: you can overwrite it, but no one, not you, not a teammate, not a compromised CI token, can ever read its value out again. The point is that a leaked dashboard session or a stolen API token can’t exfiltrate the value.
There’s good news here: vercel env add now defaults to sensitive for production and preview. So if you’re adding secrets today through the CLI, the default is correct. This is still an audit item rather than a solved problem because of what happens around that default.
A non-sensitive secret can sneak in two ways. First, anything created before that default existed is still readable, because old vars don’t retroactively flip. Second, and worth naming as the real-world watch-out: third-party integrations create variables for you, and they don’t all set the flag. The Vercel Sentry integration shipping SENTRY_AUTH_TOKEN as a plain, readable variable is a documented case. So the rule isn’t only “set the flag when you add a var,” it’s “audit what the integrations wrote.” That’s the value of checking explicitly instead of trusting that the default covered you.
Now the honest trade-off, because the experienced-engineer move is to name the cost rather than hide it. Write-only means write-only: if you forget a secret’s value, Vercel cannot give it back to you. You can only overwrite it with a new one. So a break-glass copy belongs somewhere you control, like your password manager, not Slack and not a ticket. You pay a small recovery inconvenience to buy a guarantee that the value can’t be read out of the platform, which is a trade worth making.
Local development is the other place secrets live, and it’s simpler. Put them in .env.local, which is gitignored, and source the values from your password manager rather than from a colleague pasting them into a DM. Confirm the gitignore line is there:
.env.local.env*.localWhen the team grows past a few people and copy-pasting .env.local files around stops scaling, a dedicated secrets manager like Doppler or Infisical becomes worth it, since both sync secrets to every environment and every developer from one source. Until then, Vercel’s store plus a password manager is enough. Know that the upgrade exists, but don’t reach for it early.
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 three completely different values, one per environment. Your local machine, Vercel’s preview deployments, and production each get their own keys, and production keys appear only in production.
You already follow this for most of your services without thinking of it as a rule. Stripe gives you live keys and test keys; Resend has production and sandbox. The correct posture is to extend that everywhere: a separate Upstash instance per environment and a separate Postgres database per environment, so that nothing you do in preview or on your laptop can touch real customer rows.
In 2026, the bug this prevents is 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 screenshotted. If a preview holds the production database URL, then one leaked preview link or one screenshot in a Slack thread is a production breach. Keep production keys out of preview and that entire class of accident disappears.
Here’s the same handful of services, shown per environment, so “three sets” stops being abstract.
| 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 |
Notice that the Production column is the only place the live values exist. That’s the rule in one image.
The .env.example contract
Section titled “The .env.example contract”Now to wire up the first of the two new pieces. You’ve got secrets declared in env.ts, stored in Vercel, and scoped per environment, but a brand-new developer cloning the repo has none of them and no list of what they even need. That’s what .env.example is for.
.env.example is checked into git. It lists every variable name your app needs, each with a placeholder value and a comment pointing at where to get the real one. It is the contract between your README and a running app: a new developer, or an AI agent setting up the project, copies it to .env.local and fills in the blanks. No guessing, and no DMing a teammate to ask which env vars the app needs.
One invariant makes it trustworthy: env.ts and .env.example must list the exact same variables. The schema is what the app requires; the example is what a newcomer is told to provide. When they drift, onboarding fails silently in the worst way: the app boots fine, then crashes on the first request that touches the one variable nobody knew to set. Keeping the two in lockstep is enforced automatically, and the next lesson owns that parity check. For now, know the rule and the failure it prevents.
Here’s a representative .env.example. Step through it, since 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 whole 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. This turns “fill in the blanks” from a scavenger hunt 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 right 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: rotation is a documented operation, run on events not the calendar
Section titled “Rule 5: rotation is a documented operation, run on events not the calendar”Back to the question this lesson opened with: a developer with production access just left the company, so now what? The answer is rotation, and rotation is the one stage that’s a procedure rather than a setting.
Rotation is the move here. The rule is that every secret the departing developer could have seen gets rotated. The trigger is an event, such as someone offboarding, a suspected leak, or a vendor forcing a reset, not a date on a calendar. Calendar rotation, meaning rotate everything every 90 days regardless, is mostly theater: it burns real effort on a schedule that has nothing to do with real risk, and the busywork makes people sloppy when a real event hits. Rotate on events, and skip the fixed cadence.
The part that turns a routine rotation into a self-inflicted outage if you get it backwards is order. You have a live key in two places at once: at the provider (Stripe, Resend) and in Vercel. To rotate, you must update Vercel first and confirm it’s live, and only then revoke the old key at the provider. Do it in the other order and revoke first, and there’s a window where the old key is dead but the new one isn’t deployed yet, so your app is down for every customer.
Walk through the correct order one step at a time, watching what stays valid at each step.
Provider
Vercel
Provider
Vercel
Provider
Vercel
Provider
Vercel
Provider
Vercel
The point of that diagram is the middle: for a beat, both keys are valid. That overlap is deliberate. It’s the safety margin that means there’s never an instant with no working key. Reverse the order and you delete the margin.
The artifact that makes this repeatable under pressure is a runbook . For secrets, it’s a doc in the repo listing each secret, its provider, the exact steps to rotate it, and where it lives in Vercel. When someone offboards, you don’t reconstruct the procedure from memory; you open the runbook and follow it. We’ll fold this into the deliverable at the end.
Now lock in the order. The five rotation steps below are shuffled. Drag them into the sequence that never drops a working key.
Order the rotation steps so there is never a moment without a valid key. The old key must outlive the new one's deployment. Drag the items into the correct order, then press Check.
sensitive flag, across the relevant environments. .env.local and record the rotation in the runbook. Catching leaks before they ship: pre-commit scanning
Section titled “Catching leaks before they ship: pre-commit scanning”Now to wire up the second piece. Rule 1 said git history is forever, which means the cheapest place to stop a secret is before it ever becomes a commit. After it’s committed, you’re rotating keys and scrubbing history; before, you just fix the line. So we put a guard at the commit boundary that scans your staged changes and blocks the commit if it finds a secret. It fails closed: the commit doesn’t happen until the secret is gone.
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. The standard way to manage Git hooks in a JavaScript project is Husky , which matters because a hook that only exists 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 any teammate runspnpm install. That auto-install on clone is exactly why Husky beats a hand-written.git/hooksscript:.git/hooksisn’t committed and never reaches anyone else.Terminal window pnpm dlx husky init -
Replace the contents of
.husky/pre-commitwith the Gitleaks scan (shown below). Install Gitleaks itself via your system package manager (e.g.brew install gitleaks) so it’s on PATH.
The hook itself is one line. The canonical command for a pre-commit hook is gitleaks protect --staged:
gitleaks protect --stagedprotect --staged scans exactly what git diff --cached would show, meaning only your staged changes, so it runs in well under a second on every commit. There’s an older detect command that scans full history, but it’s the wrong tool for a hook because it re-scans everything every time, so use protect --staged here. If Gitleaks finds a match, it exits non-zero, Husky aborts the commit, and you fix the line before anything enters history.
The leak audit: four places secrets escape
Section titled “The leak audit: four places secrets escape”Everything above is now a procedure you can run against any codebase, including yours, today. Here is the synthesis: four concrete checks, each one a grep or a tool, each tied to the rule it enforces. This is the deliverable. Run it, and every hit is a finding to fix.
process.env.X outside env.ts. Grep process.env across the repo; every hit outside env.ts bypasses type safety and the server/client split. The allowed exception is process.env.NODE_ENV. (The full invariant is the next lesson’s.)NEXT_PUBLIC_* matching a secret pattern. Grep NEXT_PUBLIC_ for any name containing SECRET, TOKEN, or KEY with no publishable or public justification; each is a value shipped to the browser by mistake..env* file in git log. Run a history scan for committed env files. If a real secret turns up, rotate it (rule 5) and purge it from history with BFG Repo-Cleaner, because deleting the file in a new commit is not enough.That’s the audit. Run it against your repo and you’ve turned five rules into a repeatable pass, which is the input to the chapter’s final hardening project.
One closing boundary, in the spirit of reaching for the heavier tool only when the scale demands it: dedicated key-management systems such as KMS, HSM, and Vault are out of scope for everything you’ll build before a Series A. Vercel’s encrypted store, the sensitive flag, three environments, and a commit-time scanner are the genuine baseline at this stage. When you’re managing encryption keys for customer data at rest, or compliance hands you a key-custody requirement, that’s the trigger to reach for a KMS. Until then, this is enough, and enough done consistently beats maximal done never.
External resources
Section titled “External resources”The canonical sources to bookmark 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.