Skip to content
Chapter 98Lesson 6

Env vars across dev, preview, and prod

How Vercel scopes one env-var schema across development, preview, and production, and OIDC federation for keyless cloud access.

Your env.ts from the invoicing project validates configuration at build time and refuses to build if a required variable is missing or malformed. But the schema only describes what shape a value must have, not which value to use. STRIPE_SECRET_KEY is one field, yet it holds your test key in development and in a teammate’s pull-request review, and your live key in production. One field, three environments, three different secrets.

Get that wiring backwards and the failure is quiet and expensive. Point a preview build at the live key, and a reviewer clicking through a draft feature charges real cards. Point production at a test key, and the app boots cleanly, passes every check, then declines every real payment. The schema catches neither: in both cases the value is a valid Stripe key, just the wrong one for where it landed.

env.ts is one file, validated the same way everywhere. What changes between environments is not the schema but the values it validates. So a configuration variable on Vercel is a triple: a key, a value, and a scope. The key and its validation rule are constant; the value is whatever the active environment supplies; the scope decides which environment that is.

Vercel gives you three scopes, each tied to a git event you saw earlier in this chapter. A push to main produces a Production deployment. A push to any other branch produces a Preview deployment. And pnpm dev on your machine, reading a local .env.local, is the Development environment.

The figure shows this: on the left, your single env.ts; on the right, the three environments, each holding the same keys with different values.

env.ts the schema — one file
  • DATABASE_URL
  • STRIPE_SECRET_KEY
  • NEXT_PUBLIC_APP_URL
Development your laptop
DATABASE_URL dev Neon branch
STRIPE_SECRET_KEY sk_test_…
NEXT_PUBLIC_APP_URL http://localhost:3000
Preview every PR
DATABASE_URL per-PR Neon branch
STRIPE_SECRET_KEY sk_test_…
NEXT_PUBLIC_APP_URL …-git-branch.vercel.app
Production main
DATABASE_URL main Neon branch
STRIPE_SECRET_KEY sk_live_…
NEXT_PUBLIC_APP_URL your real domain
One schema validates three independent sets of values.

Because only the values move, configuring the app for production is never a code change. There is no production branch of env.ts, no if (production) ladder choosing secrets. You set the right value in the right scope, in one place, outside your code.

Scope each variable one at a time. Almost every one falls into a pattern.

Pattern 1: the same value everywhere. Rare, but real: a cosmetic NEXT_PUBLIC_APP_NAME, or a publishable key identical in every environment. Set it once across all three scopes.

Pattern 2: a different value per environment. The dominant pattern, and where almost all your attention goes. Practically every external service hands you two sets of credentials: a test or sandbox set, and a live set. Stripe gives you a test secret key and a live secret key; Resend separates test sending from your verified production domain; PostHog wants laptop and preview events in a development project and only real traffic in the production project. The rule: development and preview use the test set, production uses the live set. Same key, three different values, set once per scope.

Pattern 3: present in some environments, absent in others. Some variables don’t exist everywhere: a feature flag you turn on only in Preview to dogfood something before it ships, or a SENTRY_AUTH_TOKEN that your production build needs to upload source maps but that has no job on your laptop. Your env.ts schema can mark it “required in production, optional elsewhere,” so it can be missing in dev without failing the build while staying mandatory in prod.

A value in the wrong scope is the worst kind of bug: silent, environment-specific, and invisible where you’d most likely catch it, because your laptop never exercises the production value. The only fix is discipline: decide the pattern for each variable, then set each scope deliberately.

Sort the app’s real configuration by scoping pattern.

Sort each variable by the scoping pattern it follows across Development, Preview, and Production. Drag each item into the bucket it belongs to, then press Check.

Same value everywhere One value, applied to all three scopes
Different value per environment Test set in dev/preview, live set in production
Present in some only Legitimately absent in at least one scope
STRIPE_SECRET_KEY
RESEND_API_KEY
NEXT_PUBLIC_POSTHOG_KEY
DATABASE_URL
NEXT_PUBLIC_APP_NAME
SENTRY_AUTH_TOKEN
NEXT_PUBLIC_FLAG_NEW_BILLING
Answer & why each lands where it does

Different value per environment is the default. STRIPE_SECRET_KEY, RESEND_API_KEY, and NEXT_PUBLIC_POSTHOG_KEY carry the test credential in Development and Preview and the live one in Production. DATABASE_URL too: the Neon integration injects a per-PR branch URL into Preview and the main-branch URL into Production, so one key points at three databases.

Same value everywhere is the rare case. NEXT_PUBLIC_APP_NAME is a cosmetic string, identical in every scope.

Present in some only covers variables that don’t exist everywhere: SENTRY_AUTH_TOKEN uploads source maps from Production but has no job on your laptop, and NEXT_PUBLIC_FLAG_NEW_BILLING is a preview-only flag.

Scope is a different question from the firewall sort (NEXT_PUBLIC_ vs. server-only) you did earlier. Every variable has both answers, independently: NEXT_PUBLIC_POSTHOG_KEY is public and different-per-environment, while STRIPE_SECRET_KEY is server-only and different-per-environment.

Under Settings → Environment Variables, each variable carries a key, a value, and a set of environment checkboxes for Production, Preview, and Development. Those checkboxes are the scope. Check all three with the same value for Pattern 1. For Pattern 2, add the variable twice: the test value scoped to Development and Preview, the live value to Production. An optional Git branch filter can pin a value to one preview branch, but you’ll rarely need it.

Vercel’s Settings → Environment Variables panel. Each variable is a value plus a scope — the three checkboxes are the scope. A Sensitive variable never shows its value back; a managed variable is locked because the Neon integration owns it.

Three behaviors of this panel cause most of the confusion.

First, the Sensitive flag. A sensitive variable is stored encrypted, and once saved its value can never be read back: not in the dashboard, not via the CLI, not even by an admin. It stays available to your builds and at runtime. This is the platform-level companion to the server-only import: one stops a secret reaching the browser bundle, the other stops it being read back out of the dashboard. Vercel now defaults new Production and Preview variables to sensitive, so your job is to confirm the toggle is on. It is unavailable in the Development scope, because those values have to stay readable for the next mechanic, pulling them to your laptop.

Finally, managed variables. You met one in the previous lesson: the Neon integration took over DATABASE_URL in the Preview scope, injecting a fresh per-PR branch URL into every preview deployment. It shows up locked because the integration owns the value and overwrites it on every deployment. Overriding it only breaks what the integration is doing for you.

Build-time vs. runtime: why a public var needs a redeploy

Section titled “Build-time vs. runtime: why a public var needs a redeploy”

The last wrinkle is when a value is read. Build-time variables are read once during next build: that is when NEXT_PUBLIC_* values are baked into the client bundle as literals. Runtime variables, like a server-only secret read inside a Server Action or route handler, are read fresh on each invocation.

That split decides what a dashboard edit does. Change a server-only secret and the next deployment, or even the next function cold start, picks it up with no client rebuild. Change a NEXT_PUBLIC_* value and the edit does nothing on its own: the old value is already a string constant in the shipped bundle, and only a redeploy replaces it. So if a public value looks stale after you’ve updated it, the fix is almost always to redeploy.

Production and preview get their values from Vercel at deploy time. Your laptop is the one environment Vercel can’t reach, so where do its values come from? One command:

Terminal
vercel env pull .env.local
# Downloaded `.env.local` file [120ms]

vercel env pull writes the Development-scoped variables into a gitignored .env.local. It needs a linked project, so vercel link runs first, which you did on your first deploy. This is the on-clone ritual for every developer: Vercel’s Development scope is the source of truth for local values, retiring the old dance of copying .env.example and hand-filling each secret from a password manager. When a teammate adds a development variable, everyone re-runs the command to sync. The inverse, vercel env add <KEY>, pushes a variable up from the terminal and prompts for its scopes, though the dashboard is the more common place to do that.

vercel env pull also writes a VERCEL_OIDC_TOKEN into .env.local, a short-lived credential that lets local dev talk to a cloud provider without storing a long-lived cloud key; the OIDC section returns to it.

.env.example keeps a smaller job. It stays committed as documentation: the list of keys a contributor needs to know exist, with placeholder values, for anyone without access to your Vercel project. vercel env pull supplies the actual values.

Scope reads like bookkeeping, a way to keep test and live keys from getting muddled. It is more than that: it is a security wall.

The structural guarantee is this: a Preview deployment can only read Preview-scoped variables. Production secrets are never injected into a preview build. A pull request, including one from an external contributor you’ve never met, builds a preview deployment that cannot read your live Stripe key, production database URL, or any other production secret, because Vercel never hands those values to a preview build. The platform enforces this every time, so a malicious PR that prints every secret it can reach has nothing dangerous to print.

This pairs with the supply-chain defenses from the CI chapter: there you controlled what code runs at build time (SHA-pinned Actions, minimumReleaseAge); scope controls what secrets that code can reach.

The reflex: never attach a production secret to the Preview scope to make a preview “work.” Tick the Preview box on a production secret and every future PR’s preview can read it. If a preview needs a credential, give it the test credential, scoped to Preview.

On the Pro plan and above, Vercel’s audit log records who changed which environment variable and when, so after a suspected exposure you can reconstruct what was touched and what to rotate.

Each claim is about how environment scope behaves on Vercel. Mark each statement True or False.

A pull request’s preview build can read the production STRIPE_SECRET_KEY.

Production secrets are never injected into a preview build. The preview can only read Preview-scoped variables — which is exactly why an external contributor’s PR can’t exfiltrate your live keys.

Changing a NEXT_PUBLIC_* value in the dashboard immediately updates deployments that are already live.

Public values are inlined into the bundle at build time, so the old value is frozen into existing deployments. You have to redeploy for the change to take effect.

The Neon integration’s managed DATABASE_URL in Preview points at your production database.

It points at a copy-on-write branch created per pull request — isolated from production data, which is the whole reason the integration exists.

Marking a variable Sensitive means even an admin can’t read its value back in the dashboard.

A sensitive variable is stored encrypted and its value is never shown back after save, while still being available to builds and at runtime.

OIDC: short-lived tokens instead of long-lived cloud keys

Section titled “OIDC: short-lived tokens instead of long-lived cloud keys”

Everything so far has been about storing secrets well. The last reflex is about needing fewer of them.

The “before” is code you shipped. When you wired up file uploads, you stored R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY as long-lived server secrets. That’s the historical default for talking to a cloud: mint a static key in the provider’s console, copy it into an env var, and your app authenticates with it forever. The problem is its blast radius : the key stays valid until a human notices a problem and revokes it, so a leak, into a log line, a build, or a stray commit, grants full access until someone acts. Its lifetime is unbounded.

The 2026 pattern, OIDC federation , removes the standing secret entirely.

Vercel env R2_SECRET_ACCESS_KEY lives forever
Function → Cloud (R2)
Before: a standing secret. The long-lived R2_SECRET_ACCESS_KEY lives in your environment and the function uses it directly. Valid until a human revokes it — so a leak grants full access until someone notices.
Vercel acts as Identity Provider
VERCEL_OIDC_TOKEN expires < 1h
After, step 1 of 3 — Mint. On each invocation Vercel mints a short-lived, signed JWT identifying this project and environment. No static cloud key anywhere.
VERCEL_OIDC_TOKEN expires < 1h
Cloud provider trusts Vercel's issuer
temporary credentials short-lived
After, step 2 of 3 — Exchange. The function hands that token to the cloud provider, which trusts Vercel's issuer (a one-time trust setup) and swaps it for short-lived cloud credentials.
temporary credentials expires on its own
Function → Cloud (R2)
After, step 3 of 3 — Use. The function calls the cloud with those temporary credentials, which expire on their own in under an hour. A leaked token is near-worthless — it's already expiring.

Three beats. First, Vercel acts as an identity provider : on each deployment or invocation it mints a short-lived, signed JWT identifying the project and environment making the request. In a build it’s the VERCEL_OIDC_TOKEN you saw vercel env pull write to your laptop; in a running function it arrives as a request header. Second, you set up a trust relationship once in the cloud provider, an AWS IAM role whose trust policy says “I trust tokens from Vercel’s OIDC issuer.” Third, at runtime the SDK exchanges that JWT for short-lived cloud credentials, so no long-lived secret is ever stored in Vercel. The token expires in under an hour, the whole property that matters: a static key lives until someone kills it, a federated token expires on its own.

That property reaches into your cloud account. The trust policy can grant different permissions per Vercel environment, so dev, preview, and production map to different cloud roles. It’s the same scoping wall from earlier, now extended past Vercel’s edge to the resources your app talks to.

The shift in code is small. Your lib/r2.ts already builds an S3-compatible client and hands it a credentials value; instead of a static key pair, you hand it Vercel’s federated provider. The snippet below is what it looks like, not something you wire today: the course shipped R2 with API keys, and the full OIDC setup is a deferred upgrade.

src/lib/r2.ts
import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { awsCredentialsProvider } from '@vercel/functions/oidc';
export const r2 = new S3Client({
region: 'auto',
credentials: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN,
}),
});

Identical to the lib/r2.ts you already wrote, same S3Client and region, except that credentials now comes from a federated provider instead of a static key pair.

src/lib/r2.ts
import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { awsCredentialsProvider } from '@vercel/functions/oidc';
export const r2 = new S3Client({
region: 'auto',
credentials: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN,
}),
});

The only thing you configure is which cloud role to assume. The role’s trust policy, set up once on the provider, is what permits Vercel’s tokens. The ARN is not a secret, just an identifier.

src/lib/r2.ts
import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { awsCredentialsProvider } from '@vercel/functions/oidc';
export const r2 = new S3Client({
region: 'auto',
credentials: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN,
}),
});

Notice what’s absent: no accessKeyId, no secretAccessKey. That absence is the point. The provider exchanges the short-lived VERCEL_OIDC_TOKEN for temporary credentials on each call, so no long-lived cloud secret is stored anywhere. Contrast it with the R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY pair the old file read.

1 / 1

This works locally too: the VERCEL_OIDC_TOKEN in your pulled .env.local lets your laptop join the same exchange without a static key. You don’t wire this now; file it as the reflex: when an app talks to a cloud provider, federate the identity instead of storing a long-lived key.

Secrets leak, expire, and get rotated on a schedule, so eventually you’ll need to replace one.

The naive move, editing the value in place, fails two ways. Deployments already built against the old value keep using it until redeployed, so live traffic runs on the old secret while new traffic expects the new one. And an in-place swap leaves no overlap: for a moment the old secret is dead and the new one isn’t live everywhere, and requests fall into that gap. The safe pattern trades the instant swap for a deliberate window where both secrets are valid.

  1. Generate the new secret at the provider, leaving the old one active. Most providers let you mint a second credential without revoking the first, so both now authenticate. This is the overlap window, created on purpose.

  2. Set the new value in Vercel and redeploy. Every deployment now runs on the new secret, and any still on the old one stays valid because you haven’t revoked it. Rotate each scope deliberately rather than assuming one change covers all three.

  3. Revoke the old secret at the provider. Once everything serves the new value, retire the old one. The overlap window closes, and the rotation completes with no downtime.

Which credentials to rotate and how often was covered in the pre-launch security work, and the launch checklist confirms those runbooks exist before you go live.