The env schema as single source of truth
A security-baseline audit of the @t3-oss/env-nextjs schema that validates and types every environment variable your app reads.
When this app first held a real secret, you put env.ts in charge: one file where every configuration value is validated and typed, once.
It has since absorbed variables for auth, email, Stripe, Upstash, and observability.
A dozen variables later, the senior question is whether env.ts is still the single source of truth, or whether the discipline has eroded one bypass at a time.
The previous lesson enforced one rule: no process.env.X outside env.ts.
This lesson checks whether the validation behind that rule still holds, leaving you with an audit you can run against any repo.
The four jobs of env.ts
Section titled “The four jobs of env.ts”Each invariant below protects one of four jobs env.ts has taken on as the app grew:
- The runtime gate.
createEnvruns the schema the moment the module loads. A missing or malformed variable failsnext buildand names the variable, rather than booting a half-configured app. - The type boundary.
env.DATABASE_URLis typedstring, neverstring | undefined, so nothing downstream null-checks it. - The secret/client firewall. The
serverblock is walled off from the browser bundle. Import a server variable into a Client Component and the build fails. - The documentation. The file is the exhaustive answer to what this app needs to run.
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
Invariant 1: every access goes through the typed env
Section titled “Invariant 1: every access goes through the typed env”Lock this one in first; the other three build on it.
Every read of configuration is the typed import: import { env } from '@/env', then env.WHATEVER.
A raw process.env.WHATEVER anywhere else is the warning sign.
The schema only guards what’s in it, so a variable read through raw process.env can’t be split into server or client, documented, or validated.
The two versions below read the same Stripe secret, but only the second is one the schema can see.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Skips the boundary. The value types as string | undefined; the ! papers over that, so a missing variable sails past the build unvalidated and surfaces as undefined on the first request, a 500, and a 3am outage hiding in one handler.
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 ran.
The check is a single grep for process.env across the repo; every hit outside env.ts is a finding.
Two exceptions are sanctioned: process.env.NODE_ENV, which Next.js validates itself and you’ll need for build-time branching twice later in this lesson, and 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”The schema’s server and client blocks aren’t tidy organization; they’re a firewall.
Server-only variables go in server.
Anything the browser may see goes in client and must carry the NEXT_PUBLIC_ prefix, the only prefix Next.js inlines into the browser bundle.
You can cross this line two ways, and they are not equally serious.
The dangerous one is a leak: a server variable imported into a Client Component.
That file is bundled for the browser, so the secret would be inlined into JavaScript anyone can read.
@t3-oss/env-nextjs throws at build time when you do this, so the leak can’t ship at all instead of relying on a reviewer to catch it.
A second belt does the same job: import 'server-only' atop any module that touches a secret, such as the database client, an SDK built with a private key, or the email sender.
import 'server-only'; // build error if this module is ever imported client-sideIf a client module imports a file beginning with that line, the build breaks before the env package even fires. Either belt turns a leaked import into a failed build rather than a production breach.
The other crossing is harmless: a client variable read inside a server file.
The value is already public, so this only signals confusion, worth a glance in review but not alarm.
A NEXT_PUBLIC_* name is a public promise.
Because the prefix ships the value to every visitor, the name vows it is safe for the world to read.
The trap is judging a variable by which vendor issued it rather than the authority it grants.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and NEXT_PUBLIC_SENTRY_DSN are public by design: a publishable key and a DSN only identify, granting no power.
But NEXT_PUBLIC_STRIPE_SECRET_KEY contradicts itself: the prefix promises “public” while the name says “secret,” and the prefix wins, shipping your key to the world.
Sort the variables by hand, since this is exactly where it’s easy to slip.
Decide where each 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_ENVThe two “not in the schema” items are the subtler half of the invariant.
NODE_ENV is the framework’s own variable: you read it, but you don’t declare it.
A hardcoded value isn’t configuration the environment supplies, so it has no place 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 list the same variables
Section titled “Invariant 3: the schema and .env.example list the same variables”env.ts is what the app validates; the committed .env.example is what a newcomer copies to get started.
The third invariant: they must list the same variables.
When they drift, onboarding fails silently.
Someone adds a variable to the schema and forgets the example, so the next person copies .env.example, fills it in, runs pnpm build, and hits a validation error for a variable nobody told them about.
Keep the two roles straight: .env.example is committed and holds a placeholder plus a # source: comment per variable; the git-ignored .env and .env.local hold the live values.
A CI check enforces the match on every pull request later in the course.
Once every variable goes through the schema, lands on the correct side of the split, and matches the example, the schema stops being just a validator: it’s the inventory of what the app needs to run. Here is that file, the whole course’s configuration gathered into one 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, one group per feature you added. The database came first: DATABASE_URL and its unpooled twin, 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 came next with Better Auth, a secret and a URL. Then email with Resend, an API key and a from address validated by z.email().
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 added a signing secret, rate limiting the Upstash pair, billing the Stripe secret. The shapes stay minimal: z.string().min(1) for opaque tokens, z.url() for endpoints.
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 prefixed NEXT_PUBLIC_: the app URL, the Stripe publishable key, the Sentry DSN, the PostHog key. 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 // ... },});A finding. 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.
Top to bottom, the schema lists the app’s entire dependency on the outside world, grouped by feature, which makes it your most honest documentation as long as it stays in lockstep with what the code reads.
That is why orphaned LEGACY_WEBHOOK_URL is a real finding: documentation that lies is worse than none, 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”@t3-oss/env-nextjs honors a SKIP_ENV_VALIDATION flag that returns the env object without running a single schema, switching off the whole gate.
It has exactly two legitimate homes:
- A container image build where server secrets aren’t available yet, so you build now and inject values at runtime.
- A type-check or lint CI job that doesn’t need real values to run.
Anywhere else, it’s the bug. Used correctly, it’s one line in a build script:
# Dockerfile: secrets are injected at runtime, not build timeSKIP_ENV_VALIDATION=1 pnpm buildThe trap is setting it in the production runtime to silence a missing-variable error. The build goes green, but the gate is off for good: the missing variable resurfaces as a 3am 500 on the first request instead of a build failure on your terminal.
So when the build says a variable is missing, set it; don’t silence it.
Per-environment variables
Section titled “Per-environment variables”The four invariants assume every variable behaves the same in every environment. Two complications break that assumption.
Production-only variables
Section titled “Production-only variables”Some variables are required in production but absent in development.
STRIPE_WEBHOOK_SECRET and SENTRY_AUTH_TOKEN are the canonical examples: no one needs them to run the app locally, but production must have them.
Make them unconditionally required and every local pnpm build fails over variables nobody needs.
Make them optional and a production deploy can ship with Sentry silently un-authed and source maps never uploaded.
So branch the schema on the environment, giving each the contract it needs:
const isProd = process.env.NODE_ENV === 'production';
server: { SENTRY_AUTH_TOKEN: isProd ? z.string().min(1) : z.string().optional(),},Required in production, optional everywhere else.
This is one of the two sanctioned process.env.NODE_ENV reads from invariant 1.
The per-environment URL helper
Section titled “The per-environment URL helper”Other variables aren’t present or absent; they hold a different value in each environment.
The app’s own base URL is the cleanest case: http://localhost:3000 on your laptop, a unique per-branch URL on a Vercel preview, your real domain in production.
You can’t hardcode it or pack three values into one variable.
Instead a small helper in /lib resolves the URL for whatever environment it runs in:
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';};Vercel injects VERCEL_URL (and VERCEL_PROJECT_PRODUCTION_URL) without the scheme, as a bare host like my-app-git-feature.vercel.app; forget to prepend https:// and every link the helper builds breaks.
Those raw process.env reads look like an invariant-1 violation but aren’t: Vercel injects them, they aren’t app secrets, and they live only inside this one helper.
“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, just as env.ts is the one place validation lives.
The env audit checklist
Section titled “The env audit checklist”Each check below runs against any codebase as a repeatable pass over the four invariants; 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.)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.)This audit joins the chapter’s other deliverables, which the next chapter runs against a seeded codebase.
One boundary stays out of scope: per-tenant, white-label resolution, where configuration differs per customer. One schema, split correctly and kept honest, is the right baseline here.
External resources
Section titled “External resources”Keep these sources open while you run the audit.