The env schema as single source of truth
Part of the security baseline, this lesson audits the env.ts schema that validates and types every environment variable your app reads.
Back in the first project that ever held a real secret, you wrote one file, env.ts, and gave it a quiet promise: every piece of configuration this app reads goes through here, validated and typed, once. That was the first named side-effect boundary in the whole course, and the note at the bottom of that lesson said the idea would return to anchor the security baseline near the end. This is that moment, and the baseline pass exists to confirm the door is still the only door.
A lot happened to env.ts in between. You added auth variables, then email, then invitations, then Upstash, then Stripe, then observability, a variable here and a variable there, each under its own deadline. One file was the single source of truth on day one. A dozen variables later, the senior question is whether it still is, or whether the discipline has quietly eroded one bypass at a time, the way these things tend to.
The previous lesson left you a thread to pull. Its leak audit opened with a single check, “no process.env.X outside env.ts,” and noted that the full invariant belonged to the next lesson. That next lesson is this one. The leak audit covered where secrets escape; this lesson covers whether the validation discipline still holds. By the end you’ll have a four-item audit you can run against any repo, including yours today, to answer that one question.
One file, four jobs
Section titled “One file, four jobs”Before the checks, it’s worth being precise about what env.ts actually is now that it has grown, because every invariant below protects one of its jobs. When you wrote it, the file had one obvious purpose. As the app matured, three more quietly piled onto the same file, and it now does four things at once:
- It’s the runtime gate.
createEnvruns the schema the instant the module loads. A missing or malformed variable doesn’t boot a half-configured app; it failsnext buildon your terminal and names the variable. You saw exactly this when you pulledDATABASE_URLand watched the build refuse. - It’s the type boundary.
env.DATABASE_URLis typedstring, neverstring | undefined. The build already guaranteed the value exists, so nothing downstream has to null-check it. - It’s the secret/client firewall. The
serverblock is structurally walled off from the browser bundle. Import a server variable into a Client Component and you get a build error, not a lint warning you can ignore but a failed deploy. - It’s the documentation. Open the file and you have the canonical, exhaustive answer to what this app needs to run.
All four jobs live in one file, which is the whole reason the file is worth guarding.
env.ts
one source
Runtime gate
fails the build, names the var
Type boundary
env.X is string, never undefined
Secret / client firewall
server vars can't reach the browser
Documentation
what this app needs to run
Each of the four invariants that follow keeps one of these four jobs honest as the schema grows. They run simplest first, then the two that only make sense once the basics are in hand.
Invariant 1: every access goes through the typed env
Section titled “Invariant 1: every access goes through the typed env”The first invariant is the precondition for all the others, so it’s the one to lock in first: every read of configuration is the typed import. You write import { env } from '@/env' and then env.WHATEVER. A raw process.env.WHATEVER anywhere else is the warning sign.
This is invariant one because a variable that never enters the schema can’t be protected by any of the other three. It can’t be split into server or client, it can’t be documented, and it can’t be validated. It just sits there, untyped and unchecked. The schema can only guard what’s in the schema.
The two versions below read the same Stripe secret, and the second one is the only one the schema can see.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Skips the boundary entirely. The value types as string | undefined, and the ! papers over that, so a missing variable sails past the build with nothing to validate it.
import { env } from '@/env';const stripe = new Stripe(env.STRIPE_SECRET_KEY);Covered by the schema. Through env the value is typed string, with no ! and no null check, and a missing STRIPE_SECRET_KEY would have failed the build before this line ever ran.
The failure mode is worth picturing concretely, because it’s the exact bug env.ts was installed to prevent, returning through a side door. A stray process.env.STRIPE_SECRET_KEY deep in a handler types as string | undefined. It slips past the build, because nothing in the schema validates it, and the app deploys clean. Then the first request that reaches that handler in an environment where the variable was never set gets undefined, hands it to Stripe, and returns a 500. You’re back to the 3am outage the boundary exists to prevent, except now it’s hiding in one handler instead of being caught at the door.
The check is a single grep: process.env across the whole repo. Every hit outside env.ts is a finding. The previous lesson’s leak audit already greps for exactly this; here you’re naming why it’s invariant one, not just a leak vector.
Two exceptions are sanctioned, and naming them keeps you from flagging false positives. The first is process.env.NODE_ENV: Next.js validates it itself, and you’ll genuinely need it for build-time branching, twice later in this lesson. The second is the rare case of framework internals reading their own settings, like a next.config.ts checking process.env.ANALYZE to toggle the bundle analyzer. Everything else routes through env.
Invariant 2: the server/client split is a firewall, not a label
Section titled “Invariant 2: the server/client split is a firewall, not a label”This is the load-bearing security invariant of the lesson, so it’s worth slowing down here. The schema doesn’t put variables in a server block and a client block for tidy organization. The split is a firewall. Server-only variables go in server; anything the browser is allowed to see goes in client and must carry the NEXT_PUBLIC_ prefix, the only prefix Next.js inlines into the browser bundle. Cross that line the wrong way and the consequence is structural, not cosmetic.
There are two ways to cross it, and they are not equally serious.
The dangerous one is a leak: a server variable imported into a Client Component. Because that file gets bundled for the browser, the secret would be inlined into JavaScript anyone can read. What makes the split a firewall is that @t3-oss/env-nextjs throws at build time when you do this. The leak isn’t caught only when a reviewer happens to be paying attention; it’s structurally impossible to ship. The split itself is the defense.
You have a second structural belt for the same job, which you’ve seen before: import 'server-only' at the very top of any module that touches a secret, such as the database client, an SDK constructed with a private key, or the email sender.
import 'server-only'; // build error if this module is ever imported client-sideIf any client module ever imports a file that begins with that line, the build breaks before the env package even gets a chance to fire. The two belts work on the same principle: turn a leaked import into a failed build rather than a breach you discover in production.
The other way to cross the line is mild: a client variable read inside a server file. This is harmless for security, since the value is already public and already inlined. It just signals confusion, because there’s no reason to route a server-side read through a client variable. It’s worth a glance in review, not alarm.
Now the rule that decides which block a variable belongs in.
A NEXT_PUBLIC_* name is a public promise. The prefix tells Next.js to ship the value to every visitor, so the name is a vow that the value is safe for the whole world to read.
The trap is to judge a variable by which vendor issued it instead of by the authority it grants. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and NEXT_PUBLIC_SENTRY_DSN are public by design: a publishable key and a DSN . Both only identify; neither grants any power. But NEXT_PUBLIC_STRIPE_SECRET_KEY is a self-contradicting name and a genuine breach: the prefix promises “public” while the name says “secret,” and the prefix wins, shipping your key to the world. This is the same judgment the leak audit asked of you, except here you’re sorting variables into the schema rather than just spotting names.
Sort the variables by hand, because this is precisely where it’s easy to slip. For each one below, decide where it belongs in env.ts, and watch for the two that don’t belong in the schema at all.
Sort each variable into where it belongs in `env.ts` — and spot the two that don't belong in the schema at all. Drag each item into the bucket it belongs to, then press Check.
DATABASE_URLSTRIPE_SECRET_KEYBETTER_AUTH_SECRETRESEND_API_KEYUPSTASH_REDIS_REST_TOKENINVITATION_SIGNING_SECRETNEXT_PUBLIC_APP_URLNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYNEXT_PUBLIC_SENTRY_DSNNEXT_PUBLIC_POSTHOG_KEYNODE_ENVIf the two “not in the schema” items gave you pause, that’s the subtler half of the invariant landing. NODE_ENV is the framework’s own variable: you read it, but you don’t declare it. And a value you’ve hardcoded isn’t configuration the environment supplies, so it has no business in env.ts even though it’s a “setting.” The schema is for what the environment provides, nothing more.
Invariant 3: the schema and .env.example describe the same world
Section titled “Invariant 3: the schema and .env.example describe the same world”The third invariant ties the schema to the one file a new contributor actually touches on day one. env.ts is what the app validates. The committed .env.example is what a newcomer copies to get started. The invariant is that they must list the same variables.
When they drift, onboarding fails in the most demoralizing way: silently. A new developer (or an AI agent setting up the project) copies .env.example, fills in the blanks, runs pnpm build, and hits a validation error for a variable they were never told existed, because someone added it to the schema and forgot the example. The two roles are worth holding straight: .env.example is committed, with a placeholder value and a # source: comment per variable; the real .env and .env.local are git-ignored and hold the live values. Keeping the two in lockstep is the invariant; turning that into an automatic check that runs on every pull request is the second belt, wired into CI later in the course.
This is where the payoff of the first three invariants arrives. Because every variable goes through the schema (invariant 1), the schema is split correctly (invariant 2), and it matches the example (invariant 3), the schema becomes more than a validator: it’s the inventory. It is the answer to what the app needs to run. So let’s look at the grown-up file, your scattered, course-long collection of configuration gathered into one legible map.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { // Database — the data layer DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), // Auth — sessions and sign-in BETTER_AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.url(), // Email — transactional sends RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.email(), // Invitations — signed invite tokens INVITATION_SIGNING_SECRET: z.string().min(1), // Rate limiting — Upstash Redis UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), // Billing — Stripe STRIPE_SECRET_KEY: z.string().min(1), // Observability — Sentry source maps SENTRY_AUTH_TOKEN: z.string().min(1), // Legacy — nothing reads this anymore LEGACY_WEBHOOK_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_SENTRY_DSN: z.url(), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), }, runtimeEnv: { // one line per variable, mapping each schema field to process.env.X // ... },});Reading top to bottom is reading the app’s history. The database came first, in the data-layer project: DATABASE_URL and its unpooled twin. Two URLs, both z.url(), both server-only.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { // Database — the data layer DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), // Auth — sessions and sign-in BETTER_AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.url(), // Email — transactional sends RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.email(), // Invitations — signed invite tokens INVITATION_SIGNING_SECRET: z.string().min(1), // Rate limiting — Upstash Redis UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), // Billing — Stripe STRIPE_SECRET_KEY: z.string().min(1), // Observability — Sentry source maps SENTRY_AUTH_TOKEN: z.string().min(1), // Legacy — nothing reads this anymore LEGACY_WEBHOOK_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_SENTRY_DSN: z.url(), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), }, runtimeEnv: { // one line per variable, mapping each schema field to process.env.X // ... },});Auth arrived with Better Auth, a secret and a URL. Email followed with Resend, an API key and a validated from address via z.email(). Each group is one feature you added, still legible months later.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { // Database — the data layer DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), // Auth — sessions and sign-in BETTER_AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.url(), // Email — transactional sends RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.email(), // Invitations — signed invite tokens INVITATION_SIGNING_SECRET: z.string().min(1), // Rate limiting — Upstash Redis UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), // Billing — Stripe STRIPE_SECRET_KEY: z.string().min(1), // Observability — Sentry source maps SENTRY_AUTH_TOKEN: z.string().min(1), // Legacy — nothing reads this anymore LEGACY_WEBHOOK_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_SENTRY_DSN: z.url(), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), }, runtimeEnv: { // one line per variable, mapping each schema field to process.env.X // ... },});Invitations brought a signing secret, rate limiting brought the Upstash pair, and billing brought the Stripe secret. Notice the shape: top-level Zod builders, z.string().min(1) for opaque tokens, and z.url() for endpoints, minimal but real.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { // Database — the data layer DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), // Auth — sessions and sign-in BETTER_AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.url(), // Email — transactional sends RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.email(), // Invitations — signed invite tokens INVITATION_SIGNING_SECRET: z.string().min(1), // Rate limiting — Upstash Redis UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), // Billing — Stripe STRIPE_SECRET_KEY: z.string().min(1), // Observability — Sentry source maps SENTRY_AUTH_TOKEN: z.string().min(1), // Legacy — nothing reads this anymore LEGACY_WEBHOOK_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_SENTRY_DSN: z.url(), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), }, runtimeEnv: { // one line per variable, mapping each schema field to process.env.X // ... },});Every browser-bound variable lives here, each one prefixed NEXT_PUBLIC_. The publishable key, the Sentry DSN, the PostHog key, and the app URL are all public by design, kept in their own walled-off block. This is the firewall from invariant 2 made concrete.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { // Database — the data layer DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), // Auth — sessions and sign-in BETTER_AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.url(), // Email — transactional sends RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.email(), // Invitations — signed invite tokens INVITATION_SIGNING_SECRET: z.string().min(1), // Rate limiting — Upstash Redis UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), // Billing — Stripe STRIPE_SECRET_KEY: z.string().min(1), // Observability — Sentry source maps SENTRY_AUTH_TOKEN: z.string().min(1), // Legacy — nothing reads this anymore LEGACY_WEBHOOK_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_SENTRY_DSN: z.url(), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), }, runtimeEnv: { // one line per variable, mapping each schema field to process.env.X // ... },});This one is a finding. It’s in the schema, so the build still requires it, but no code reads it anymore. Dead config is a documentation lie: the file claims the app needs a variable it doesn’t. An orphaned variable is a deletion, not a default.
Read that file top to bottom and you’ve read the app’s entire dependency on the outside world: every database, every third-party service, and every secret, grouped by the feature that needs it. That’s invariant 3’s reward. The schema doubles as the most honest documentation you have, as long as it stays in lockstep with what the code actually reads. This is exactly why the orphaned LEGACY_WEBHOOK_URL is a real finding and not a harmless leftover: documentation that lies is worse than no documentation, because people trust it.
Invariant 4: SKIP_ENV_VALIDATION is an escape hatch, not a setting
Section titled “Invariant 4: SKIP_ENV_VALIDATION is an escape hatch, not a setting”The fourth invariant guards the one feature that can undo all the others. @t3-oss/env-nextjs honors a SKIP_ENV_VALIDATION flag: set it, and createEnv returns the env object without running a single schema. That switches off the whole gate.
The earlier lesson named one honest use for it. The baseline pass sharpens that to a precise rule: there are exactly two legitimate places for this flag.
- A Docker or container image build where the server secrets genuinely aren’t injected at build time, so you build the image now and supply the values at runtime.
- A type-check or lint CI job that doesn’t need real values to do its work.
Anywhere else, it’s the bug. Used correctly, it’s a single line in a build script:
# Dockerfile: secrets are injected at runtime, not build timeSKIP_ENV_VALIDATION=1 pnpm buildNow the failure mode that makes it dangerous. Imagine a production build complains that a variable is missing. The lazy fix, the one that feels like progress but is actually a breach, is to set SKIP_ENV_VALIDATION=1 in the production runtime environment to make the error go away. The build goes green, but you’ve made the escape hatch permanent: the gate is off in production for good. Now a genuinely missing variable doesn’t surface at deploy as a build failure on your terminal. It surfaces at first request as a 3am 500, exactly the outage the boundary was installed to prevent. The escape hatch undoes the entire feature.
So when the build says a variable is missing, treat the variable as missing. Set it; don’t silence it.
When the schema grows up: per-environment variables
Section titled “When the schema grows up: per-environment variables”The four invariants assume every variable behaves the same in every environment. Two real complications break that assumption, and they only make sense now that the invariants are in hand. Keep them in your back pocket; they’re the necessary tail, not the core.
Production-only variables
Section titled “Production-only variables”Some variables are required in production but genuinely absent in development. STRIPE_WEBHOOK_SECRET and SENTRY_AUTH_TOKEN are the canonical examples: no developer needs them to run the app on their laptop, but production must have them.
You can’t make them unconditionally required, because every local pnpm build would fail for variables nobody needs locally. And you can’t make them unconditionally optional, because a production deploy could then ship with Sentry silently un-authed and source maps never uploaded. The fix is to branch the schema on the environment, giving each environment exactly the contract it needs:
const isProd = process.env.NODE_ENV === 'production';
server: { SENTRY_AUTH_TOKEN: isProd ? z.string().min(1) : z.string().optional(),},In production the variable is required; everywhere else it’s optional. This is one of the two sanctioned process.env.NODE_ENV reads from invariant 1, the exception earning its keep.
The per-environment URL helper
Section titled “The per-environment URL helper”The other complication is subtler. Some variables don’t switch between present and absent; they hold a different value in each environment. The app’s own base URL is the cleanest case: it’s http://localhost:3000 on your laptop, a unique per-branch URL on a Vercel preview deployment, and your real domain in production.
You can’t hardcode it, and you can’t shove three values into one variable. The pattern is a small helper in /lib that resolves the URL for whatever environment it’s running in, the one place this per-environment branching lives:
import { env } from '@/env';
export const getAppUrl = (): string => { if (process.env.NODE_ENV === 'production') return env.NEXT_PUBLIC_APP_URL; if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; return 'http://localhost:3000';};Two things deserve a callout. First, the gotcha that bites everyone once: Vercel injects VERCEL_URL (and VERCEL_PROJECT_PRODUCTION_URL) without the https:// scheme, so it’s a bare host like my-app-git-feature.vercel.app. Forget to prepend the scheme and every link the helper builds is broken. Second, those VERCEL_* reads sit right next to a process.env call, which looks like an invariant-1 violation but isn’t. Vercel injects them, they’re not app secrets, and they live only inside this one helper. That’s the narrow framework-internal exception, kept narrow so the invariant stays coherent.
The deeper point is that “single source of truth” extends past validation to derived config. The URL is computed, not stored, and getAppUrl() is the one place that computation lives, for the same reason env.ts is the one place validation lives. One door, again.
The env audit: one page, four invariants
Section titled “The env audit: one page, four invariants”Every check above is now a line you can run against any codebase, including yours today. This is the synthesis: the four invariants rolled into a repeatable pass, the deliverable this lesson exists to produce. Run it, and every hit is a finding to fix.
env. Grep process.env across the repo; every hit outside env.ts is a finding. Allowed exceptions: process.env.NODE_ENV and rare framework reads like next.config.ts. (Invariant 1, the check the previous lesson’s leak audit forward-referenced.)server-block variable imported in a Client Component (the build catches it; confirm import 'server-only' guards every secret-bearing module), and no NEXT_PUBLIC_* name that grants real authority. (Invariant 2.).env.example in lockstep. Diff the two variable lists; every schema variable has a placeholder and a # source: line in .env.example, and vice versa. Flag any orphaned schema variable no code reads. (Invariant 3.)SKIP_ENV_VALIDATION outside the two sanctioned scripts. Grep for it; it belongs only in the Docker build and the type-check CI job, never in production runtime env vars. (Invariant 4.)NODE_ENV-conditional, and the normal build passes without SKIP_ENV_VALIDATION. (The extensions.)That’s the audit. It joins the rest of this chapter’s deliverables (the header check, the rate-limit matrix, the audit-event catalog, the retention catalog, the consent reject-path test, and the leak audit) as one of the things the next chapter’s pre-launch audit project runs against a seeded codebase.
One boundary is worth naming and then leaving alone: per-customer, white-label env resolution, meaning different configuration per tenant, is a real thing, and it’s beyond where this app is. The single schema, split correctly and kept honest, is the genuine baseline at this stage. One source of truth kept consistent beats one that’s infinitely flexible and never consistent.
External resources
Section titled “External resources”Keep these sources open while you run the audit.