Skip to content
Chapter 81Lesson 6

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

The life of one secret, stage by stage. Each rule below governs one stage.

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:

src/lib/billing.ts
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:

src/lib/billing.ts
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.

src/components/checkout-button.tsx
'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.

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:

src/lib/stripe.ts
import 'server-only'; // build error if this module is ever imported client-side

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

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.

Safe as NEXT_PUBLIC_ Public by design — identifies, grants no authority
Server-only — never NEXT_PUBLIC_ Grants read/write authority
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
NEXT_PUBLIC_POSTHOG_KEY
NEXT_PUBLIC_SENTRY_DSN
NEXT_PUBLIC_APP_URL
STRIPE_SECRET_KEY
DATABASE_URL
BETTER_AUTH_SECRET
RESEND_API_KEY
UPSTASH_REDIS_REST_TOKEN
SENTRY_AUTH_TOKEN

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

.gitignore
.env.local
.env*.local

Once 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
Three environments, three sets. The live values exist in exactly one column, the Scoped stage of the lifecycle strip.

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.

Terminal window
# --- Database ---
# source: Neon dashboard → Connection string
DATABASE_URL="postgres://user:password@host/db"
# --- Auth ---
# source: openssl rand -base64 32
BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---
# source: Stripe dashboard → Developers → API keys
STRIPE_SECRET_KEY="sk_test_replace_me"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---
# source: Resend dashboard → API Keys
RESEND_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.

Terminal window
# --- Database ---
# source: Neon dashboard → Connection string
DATABASE_URL="postgres://user:password@host/db"
# --- Auth ---
# source: openssl rand -base64 32
BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---
# source: Stripe dashboard → Developers → API keys
STRIPE_SECRET_KEY="sk_test_replace_me"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---
# source: Resend dashboard → API Keys
RESEND_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.

Terminal window
# --- Database ---
# source: Neon dashboard → Connection string
DATABASE_URL="postgres://user:password@host/db"
# --- Auth ---
# source: openssl rand -base64 32
BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---
# source: Stripe dashboard → Developers → API keys
STRIPE_SECRET_KEY="sk_test_replace_me"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---
# source: Resend dashboard → API Keys
RESEND_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.

Terminal window
# --- Database ---
# source: Neon dashboard → Connection string
DATABASE_URL="postgres://user:password@host/db"
# --- Auth ---
# source: openssl rand -base64 32
BETTER_AUTH_SECRET="replace-with-32-byte-random-string"
# --- Billing ---
# source: Stripe dashboard → Developers → API keys
STRIPE_SECRET_KEY="sk_test_replace_me"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_replace_me"
# --- Email ---
# source: Resend dashboard → API Keys
RESEND_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.

1 / 1

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

old key
new key

Vercel

old key
Generate new at provider. Create the new secret in the provider dashboard. The old one is still active — nothing has changed for the running app yet.

Provider

old key
new key

Vercel

old key
new key
Add to Vercel. Add (or overwrite) the new value in Vercel, with the sensitive flag, for every environment that needs it. Both are valid right now — this overlap is the safety margin.

Provider

old key
new key

Vercel

old key
new key
✓ app healthy
Redeploy and verify. Redeploy and confirm the app is healthy on the new value. Only proceed once you've verified — this is the gate.

Provider

old key
new key

Vercel

old key
new key
✓ app healthy
Revoke old at provider. Now revoke the old secret at the provider. The app is already running on the new one, so revoking the old one is invisible to users.

Provider

new key

Vercel

new key
✓ app healthy
Record it. Update .env.local from the password manager and note the rotation in the runbook — date, secret, who triggered it.

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:

  1. Add Husky as a dev dependency.

    Terminal window
    pnpm add -D husky
  2. Scaffold the hooks directory. This creates .husky/ and adds a prepare script to package.json, so the hook auto-installs whenever a teammate runs pnpm install. A hand-written .git/hooks script isn’t committed, so it never reaches anyone else.

    Terminal window
    pnpm dlx husky init
  3. Replace the contents of .husky/pre-commit with 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:

.husky/pre-commit
gitleaks protect --staged

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

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.

No 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.
No 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.
No .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.
No env-shaped strings reaching telemetry. Confirm secrets aren’t landing in your logs, a log drain , Sentry, or PostHog payloads. The log redactor from the previous chapter is the defense; check that it covers your secret-bearing fields.

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.

The canonical sources for the pieces this lesson wired.