SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 24, 2026·Security·SecureStartKit Team

Next.js Environment Variables: 6 Leak Modes [2026]

Six Next.js environment variable leak modes (NEXT_PUBLIC drift, middleware fallthrough, build-time inlining, Vercel scope) and the architectural fixes.

Summarize with AI

On this page

  • Table of contents
  • Why are Next.js environment variables a top leak class in 2026?
  • How does Next.js decide what becomes a client-side secret?
  • What are the 6 environment variable leak modes specific to Next.js?
  • Leak mode 1: NEXT_PUBLIC drift via refactor or AI assist
  • Leak mode 2: server module accidentally imported from a client component
  • Leak mode 3: middleware that works locally but breaks on Vercel
  • Leak mode 4: build-time inlining vs runtime resolution mismatch
  • Leak mode 5: secrets committed to .env or to git history
  • Leak mode 6: Vercel preview deployments inheriting production scope
  • How do you validate environment variables at boot?
  • How do you enforce server-only environment access?
  • How should you scope environment variables on Vercel?
  • What does this mean for your env var architecture?

On this page

  • Table of contents
  • Why are Next.js environment variables a top leak class in 2026?
  • How does Next.js decide what becomes a client-side secret?
  • What are the 6 environment variable leak modes specific to Next.js?
  • Leak mode 1: NEXT_PUBLIC drift via refactor or AI assist
  • Leak mode 2: server module accidentally imported from a client component
  • Leak mode 3: middleware that works locally but breaks on Vercel
  • Leak mode 4: build-time inlining vs runtime resolution mismatch
  • Leak mode 5: secrets committed to .env or to git history
  • Leak mode 6: Vercel preview deployments inheriting production scope
  • How do you validate environment variables at boot?
  • How do you enforce server-only environment access?
  • How should you scope environment variables on Vercel?
  • What does this mean for your env var architecture?

The six Next.js environment variable leak modes in 2026, in order of frequency: a NEXT_PUBLIC_ prefix added to a sensitive variable to "make it work" in a client component, server-side modules accidentally imported from client components without a server-only barrier, middleware that reads process.env locally but returns undefined on Vercel because only NEXT_PUBLIC_ reaches the edge runtime, build-time inlining that freezes a value into the static bundle when the operator expected runtime resolution, secrets committed to a tracked .env file or to history before .gitignore caught them, and Vercel environment variables scoped to "All Environments" that leak production credentials into preview deployments.

This is the cluster 3.3 pillar in the Data Layer macro. The cluster 3.2 pillar on exposed API keys covers what happens after a key gets out (Google's $82K incident, AI-tool surface area, automated detection). This one stays upstream: the six structural ways a key gets out of Next.js in the first place, and the architectural defenses that close each path. The framing comes from backend-only data access: treat the environment as a server-only API, not a global namespace.

TL;DR:

  • NEXT_PUBLIC_ is a one-way street. Anything with the prefix is inlined into the client bundle at next build and is downloadable by every visitor for the life of that build [1].
  • The six leak modes are structural, not procedural. They show up because the Next.js build pipeline treats different runtimes (Node, edge, browser) inconsistently, and because .env files predate App Router conventions.
  • server-only is a build-time barrier, not a runtime check. Importing a server-only module from a Client Component fails the build, which is exactly what you want [2].
  • Validate every env var at boot with a Zod schema. A wrong-prefix Stripe key or a missing webhook secret should fail at startup, not at the first webhook delivery in production.
  • Vercel scopes are load-bearing. A secret marked "All Environments" is reachable from preview deploys (often public URLs) and from local pulls. Production-only is the default posture for anything sensitive [4].

Table of contents

  • Why are Next.js environment variables a top leak class in 2026?
  • How does Next.js decide what becomes a client-side secret?
  • What are the 6 environment variable leak modes specific to Next.js?
  • How do you validate environment variables at boot?
  • How do you enforce server-only environment access?
  • How should you scope environment variables on Vercel?
  • What does this mean for your env var architecture?

Why are Next.js environment variables a top leak class in 2026?

Environment-variable mishandling is now the highest-frequency root cause of credential leaks in SaaS apps built with Next.js. A January 2026 cybersecurity report from SupaExplorer scanned 20,052 launch URLs from five indie product directories and found 2,325 critical credential exposures, an 11.04% rate; the worst class was the SUPABASE_SERVICE_ROLE_KEY, which bypasses Row Level Security entirely [6]. The pattern was almost never "the developer typed a secret into a Client Component on purpose." It was a structural mistake the framework allowed.

The reason Next.js sits at the center of this leak class is architectural, not because Next.js is more insecure than other frameworks. The App Router blurs three runtimes (Node.js, edge, browser) inside one source tree, and environment variables behave differently in each one. A value that is process.env.SUPABASE_SERVICE_ROLE_KEY in a Server Action is undefined in a Client Component, is undefined in middleware on Vercel but defined in local dev, and gets inlined into the static bundle the moment a developer adds the NEXT_PUBLIC_ prefix to "fix" the undefined. The runtime asymmetry is the substrate that the six leak modes below grow on.

The fix is to stop treating process.env as a global namespace and start treating it as a typed, server-only API with explicit boundaries. That reframing collapses the six leak modes into four architectural defenses (Zod schema at boot, server-only import barrier on every server module, Vercel scope discipline, and an audit script that scans the built bundle for known secret prefixes). The rest of this post walks the leak modes first so the defenses earn their place.

How does Next.js decide what becomes a client-side secret?

Next.js decides at build time, not at runtime. The exact rule from the official documentation: "In order to make the value of an environment variable accessible in the browser, Next.js can 'inline' a value, at build time, into the js bundle that is delivered to the client, replacing all references to process.env.[variable] with a hard-coded value. To tell it to do this, you just have to prefix the variable with NEXT_PUBLIC_." [1]

Three properties of this rule matter for security.

First, inlining is permanent. "After being built, your app will no longer respond to changes to these environment variables. For instance, if you use a Heroku pipeline to promote slugs built in one environment to another environment, or if you build and deploy a single Docker image to multiple environments, all NEXT_PUBLIC_ variables will be frozen with the value evaluated at build time" [1]. A wrong-prefix variable shipped to the browser is downloadable by anyone who can fetch .next/static/*.js for the life of that deploy.

Second, dynamic lookups are not inlined. A literal process.env.NEXT_PUBLIC_API gets replaced; a process.env[varName] with a runtime-computed name does not [1]. This is a useful escape hatch for runtime config, and a footgun if a developer reaches for it to "fix" a missing variable, because the same string can now resolve to a secret on the server and undefined on the client.

Third, only NEXT_PUBLIC reaches the edge runtime in deployment. Middleware in Next.js runs on the edge runtime by default, and Vercel treats edge-targeted bundles the same as client bundles for environment access [3]. Variables without the prefix are bundled out. In next dev, all of process.env is present because middleware runs in the Node.js dev server, which creates a dangerous local-vs-deployed divergence (leak mode three below).

Lock these three properties down and most of the runtime confusion disappears. The hard part is catching the moment a developer or an AI assistant breaks one of them.

What are the 6 environment variable leak modes specific to Next.js?

Each of these patterns has shipped to production in real Next.js apps audited in 2025-2026. The order is by frequency in the SupaExplorer corpus and in the breaches catalogued in the Macro 6 vibe-coding posts.

Leak mode 1: NEXT_PUBLIC drift via refactor or AI assist

The most common leak. A Server Action reads process.env.STRIPE_SECRET_KEY. A developer (or an AI assistant) refactors the call into a Client Component because "the form needs to know the key to render the price." The call now fails because client code cannot read non-public env vars, so the developer prefixes the variable: NEXT_PUBLIC_STRIPE_SECRET_KEY. The build succeeds. The secret is now in every visitor's browser bundle.

The structural fix is to make the boundary detectable at build time, not at code review. import 'server-only' (Section 5) is the cleanest: any client-side import path that reaches a server-only module fails the build with a clear error, so the refactor never compiles. Pairing the import barrier with a strict Zod schema (Section 4) means even a determined prefix-and-pray attempt produces a startup error because the wrong-prefix variable also fails the z.string().startsWith('sk_') validator if a developer relocates the value entirely.

Leak mode 2: server module accidentally imported from a client component

A subtler version of leak mode 1. The server module never gets a NEXT_PUBLIC_ prefix, but it gets pulled into the client bundle because something it transitively imports is reachable from a 'use client' file. Even without a direct secret, the act of bundling a server module client-side exposes the function signatures, the SQL queries, and sometimes the validation logic that an attacker uses to map out the data layer.

Next.js's official guidance is explicit: "To prevent server-only code from being executed on the client, you can mark a module with the server-only package" [2]. The package is a build-time barrier; the contents of the NPM package itself are not used. Next.js handles the import internally and causes a build error if the module is imported in the client environment [2]. The cost is one line at the top of every server module that touches secrets or business logic.

Leak mode 3: middleware that works locally but breaks on Vercel

A developer writes a middleware.ts that gates routes by reading process.env.ADMIN_TOKEN and comparing it to a header. It works in next dev. It ships. In production every request gets a 500 because process.env.ADMIN_TOKEN is undefined. The developer adds the NEXT_PUBLIC_ prefix to the token. The error disappears. The admin token is now in every client bundle.

The root cause is documented behavior: "In local development, ANY environment variable in your local .env file is made available to middleware.ts files. In dev/branch/production environment on Vercel, only environment variables in your .env file prefaced with NEXT_PUBLIC_ are made available to middleware.ts files" [3]. The fix is never to NEXT_PUBLIC the variable. Either route the check through a Server Component or Server Action (Node.js runtime, full env access), use Vercel's runtime config mechanism, or restructure so the middleware only reads values that are genuinely public.

Leak mode 4: build-time inlining vs runtime resolution mismatch

The team uses a single Docker image promoted from staging to production. The image was built with NEXT_PUBLIC_API_URL=https://api.staging.example.com. In production, the operator sets NEXT_PUBLIC_API_URL=https://api.example.com in the runtime environment. The production app calls the staging API anyway because the literal string was inlined into the bundle at build time and frozen [1].

The same mechanic causes the inverse leak: a key intended to vary by environment gets baked in at build time with the production value, then ships to lower environments via the same image. Anyone with access to a staging deploy can extract the production credential from the bundle. The fix is to use non-prefixed variables for anything that varies per environment, read them server-side via dynamic process.env access (which is not inlined), and reserve NEXT_PUBLIC_ for true public constants like analytics IDs and public app URLs.

Leak mode 5: secrets committed to .env or to git history

The default create-next-app .gitignore excludes .env*.local but does NOT exclude plain .env. A developer adds a real key to .env to share with a teammate, commits, and pushes. The repo is private at first, becomes public later, or gets cloned by a contractor. The 2026 SupaExplorer study found this pattern in thousands of indie SaaS launches [6]. Once a secret is in a tracked file even briefly, it is in git history forever; deleting it in a follow-up commit does nothing because every clone still contains it.

The architectural defenses are layered. Tighten .gitignore to exclude .env itself (with an explicit .env.example committed to document the schema). Add pre-commit secret scanning with git-secrets, gitleaks, or trufflehog. Enable GitHub's push protection on Advanced Security. And rotate any key that ever appeared in a tracked file, including history; a leaked key is compromised the moment it shipped, not when an attacker uses it.

Leak mode 6: Vercel preview deployments inheriting production scope

Vercel environment variables get assigned to one or more environments: Development, Preview, Production [4]. A variable assigned to "All Environments" is reachable from preview deploys, which carry unique URLs that often get shared in PR previews, posted to internal Slack, or indexed by accident. A SUPABASE_SERVICE_ROLE_KEY set for all environments means every preview deploy has full database access using the production credential, and any preview-deploy compromise (a malicious branch, a vulnerable PR, a misconfigured authentication bypass) reaches production data.

Vercel ships "sensitive environment variables" as a hardening layer; values stored as sensitive cannot be read back via the dashboard or CLI after they are set [5]. The architectural posture is: production-only by default for anything sensitive, preview-only for staging credentials that mimic but do not equal production, and explicit per-branch overrides for short-lived feature flags. Treat the "All Environments" toggle as a smell.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

How do you validate environment variables at boot?

Define a Zod schema for every variable the app expects, parse it on first access, and crash the process on any failure. The point is to fail at startup with a readable error, not at request 500 the first time the variable is dereferenced inside a Server Action.

The pattern we ship in SecureStartKit:

// lib/env.ts
import { z } from 'zod'

const baseSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
  NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
  RESEND_API_KEY: z.string().startsWith('re_'),
})

const stripeSchema = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
})

const envSchema = baseSchema.merge(stripeSchema)

type Env = z.infer<typeof envSchema>

let _env: Env | undefined

export function getEnv(): Env {
  if (!_env) {
    _env = envSchema.parse(process.env)
  }
  return _env
}

Three properties of this pattern are doing the security work.

Prefix validators catch wrong-key-in-wrong-slot. STRIPE_SECRET_KEY must start with sk_. STRIPE_WEBHOOK_SECRET must start with whsec_. RESEND_API_KEY must start with re_. A developer who mixes a publishable key into the secret slot, or pastes a Stripe key into the Resend variable, gets a parse error at boot instead of a 500 the first time a customer pays. The prefix discipline mirrors how the API providers themselves structure their key formats.

The NEXT_PUBLIC variables are in the schema, not free-floating. The schema is the single source of truth for which variables are public and which are server-only. A developer adding NEXT_PUBLIC_STRIPE_SECRET_KEY to .env.local does not get a silent inlining; they get a TypeScript type error and a schema parse failure because NEXT_PUBLIC_STRIPE_SECRET_KEY is not a key in the schema.

Validation is lazy but eager-after-first-access. The first call to getEnv() parses; subsequent calls return the cached object. Lazy parsing avoids breaking the build when an unrelated tool imports the file, but the first Server Action invocation triggers full validation, so a misconfigured production env crashes immediately on the first real request.

If you want a more formalized version of the same pattern, @t3-oss/env-nextjs separates client and server schemas explicitly, distinguishes runtime mapping from build-time mapping, and supports any Standard Schema validator including Zod. The structure is more verbose; the security properties are the same.

How do you enforce server-only environment access?

Two layers, both required. The first is the server-only import barrier on every module that touches secrets. The second is a Data Access Layer (DAL) pattern where process.env is read ONLY inside the DAL and never reaches Server Components, Server Actions, or proxy.ts.

The server-only barrier looks like this at the top of a server module:

// lib/supabase/server.ts
import 'server-only'

import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
import { getEnv } from '@/lib/env'

export function createAdminClient() {
  const env = getEnv()
  return createClient<Database>(
    env.NEXT_PUBLIC_SUPABASE_URL,
    env.SUPABASE_SERVICE_ROLE_KEY,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    }
  )
}

Two things to notice. First, import 'server-only' is the single line that converts "this module reads a secret" into "this module fails to build if anything client-side reaches it" [2]. The Next.js docs are explicit that this is the recommended barrier: "This ensures that proprietary code or internal business logic stays on the server by causing a build error if the module is imported in the client environment" [2]. Second, the env access goes through the typed getEnv() from Section 4, not direct process.env. A future refactor cannot accidentally read a non-validated variable because TypeScript will not let it.

The DAL pattern from the backend-only data access pillar extends the same posture to data: the only callers of createAdminClient() are inside the DAL, and the DAL is the only thing that reads process.env. Even Server Actions delegate to the DAL rather than touching the admin client or secrets directly. This shrinks the surface area where env access can leak from "every server file" to "one module reviewed line-by-line."

Pair this with React's experimental Taint API (experimental_taintObjectReference, experimental_taintUniqueValue) when the codebase needs an additional runtime check that a tainted secret value never reaches the client render context [2]. Next.js's own guidance is honest about its scope: "you should not solely rely on it to prevent leaking secrets." Treat tainting as a tripwire, not a wall.

How should you scope environment variables on Vercel?

Three scopes (Development, Preview, Production), plus the Sensitive flag for any variable holding a real secret. The defaults work against you if you accept them: marking a variable "All Environments" is the easy path and the wrong path for anything sensitive.

The posture we recommend:

  • Production-only for live secrets. SUPABASE_SERVICE_ROLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY all scoped to Production only. Mark every one as Sensitive so the value cannot be read back via dashboard or CLI after it is set [5]. Preview deploys never see these.
  • Preview-only for staging mirrors. Maintain a separate Stripe test account, a separate Supabase project (or staging schema), and a sandbox Resend key. Scope these to Preview only. Preview deploys exercise the full payment and email flows against environments where a leaked credential or a malicious preview branch does no real harm.
  • Development for local-only defaults. Values that only matter in next dev (a local Supabase URL, a non-secret local-test key). These are also acceptable in a committed .env.example for documentation.
  • Sensitive flag on by default. The Vercel CLI defaults to sensitive when adding via vercel env add for production and preview. Honor the default. The cost is that you cannot read the value back, which is exactly the property you want.

The architectural insight: each environment should run with the least credential that lets the app function. A preview deploy with the production service-role key is not a "convenient debugging shortcut"; it is a production credential one URL share away from exfiltration. The same logic applies to webhook secrets, email API keys, and any third-party token. Scope tight, document the schema in .env.example, and let the platform's environment separation do the security work.

For a broader pre-launch sweep that catches Vercel scope mistakes alongside other configuration gaps, see the 12-check pre-launch audit and the pre-launch security checklist tool.

What does this mean for your env var architecture?

Three commitments, all enforced architecturally rather than procedurally:

  • A typed env API at boot. getEnv() from a single lib/env.ts module with Zod validation, prefix validators for known-format keys, and lazy parsing. Direct process.env access becomes a code-review smell; the typed accessor is the only legitimate way to read environment values.
  • import 'server-only' on every server module. The build-time barrier turns leak mode 1 and leak mode 2 into compile errors instead of post-deploy incidents. One line per file, applied to lib/supabase/server.ts, lib/stripe/server.ts, lib/resend/*, every Server Action, and any module that touches secrets [2].
  • Vercel scopes as a security layer. Production-only and Sensitive for live credentials [5], Preview-only for staging mirrors, Development for local defaults, and a committed .env.example documenting the schema without secrets. The "All Environments" toggle is reserved for genuinely public values.

The pattern SecureStartKit ships uses all three. The security architecture comparison walks through how this stack of small commitments compares against templates that leave env handling to the developer. If you want a broader frame for why backend-only access is the architectural prerequisite for all of this, the backend-only data access pillar covers the case in full.

Environment variables are not a developer-experience problem with a security side effect. They are the seam where every secret your SaaS depends on enters the running process, and the framework will not protect you from the wrong prefix or the wrong scope. Make the seam typed, server-only, and scope-disciplined, and most of the 2026 leak class disappears before it can ship.

Frequently Asked Questions

Are NEXT_PUBLIC environment variables a security risk?
Only when the wrong variable carries the prefix. The `NEXT_PUBLIC_` prefix is an explicit instruction to inline a value into the client JavaScript bundle at build time. Values like a Supabase anon key, a public domain, or a Stripe publishable key are designed to be public and belong there. A `SUPABASE_SERVICE_ROLE_KEY`, `STRIPE_SECRET_KEY`, `RESEND_API_KEY`, or any AI provider key does NOT belong there. Once a secret is built into the bundle, it is downloadable by every visitor; no runtime check can put it back.
Why are my environment variables undefined in middleware on Vercel but work locally?
Vercel treats middleware (and edge-targeted bundles) the same as client bundles: only variables prefixed with `NEXT_PUBLIC_` are exposed at runtime. In local development, ANY value in `.env.local` is reachable from `middleware.ts`. In deployed environments only NEXT_PUBLIC ones are. The fix is either to move the variable to a runtime config that middleware can fetch, refactor the check into a Server Component or Server Action that runs in the Node.js runtime, or use Vercel's runtime env approach with non-inlined process.env access. Never just NEXT_PUBLIC the variable to make the error go away.
What is the difference between .env, .env.local, .env.development, and .env.production?
Next.js loads in a fixed order, stopping at the first match: `process.env`, then `.env.$(NODE_ENV).local`, then `.env.local`, then `.env.$(NODE_ENV)`, then `.env`. The `.local` files are intended for personal overrides that should never be committed; `create-next-app` adds them to `.gitignore` by default. `.env.test` is a special case: `.env.local` is NOT loaded under `NODE_ENV=test` so tests get deterministic defaults. Values committed to plain `.env` ship to every collaborator's machine; treat that file as public configuration only.
Should I commit .env files to Git?
Commit `.env` only if it contains placeholder values or non-secret defaults. Never commit `.env.local`, `.env.production.local`, `.env.development.local`, or any file with a real secret. The default `create-next-app` `.gitignore` already excludes `.env*.local` but does NOT exclude bare `.env`; that file is yours to manage. A leaked key in `.env` is forever, even after a follow-up commit removes it: every cloned copy of the repo still contains the secret in history. Rotate any key that ever appeared in a tracked file, including history.
How do I validate environment variables at boot in Next.js?
Define a Zod (or any Standard Schema validator) schema for every variable the app expects, then parse `process.env` lazily on first access so a misconfigured environment fails fast with a readable error instead of silently breaking a route deep in production. Use prefix validators (`z.string().startsWith('sk_')` for Stripe secret, `z.string().startsWith('whsec_')` for webhook secrets, `z.string().startsWith('re_')` for Resend) to catch wrong-key-in-wrong-slot mistakes. Tools like `@t3-oss/env-nextjs` formalize this pattern with separate client/server schemas and runtime mapping. The point is failing at boot, not at request 500 the first time the variable is dereferenced.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. How to use environment variables in Next.js— nextjs.org
  2. How to think about data security in Next.js— nextjs.org
  3. Inconsistency with environment variables in middleware— github.com
  4. Environment Variables, Vercel Documentation— vercel.com
  5. Sensitive environment variables, Vercel Documentation— vercel.com
  6. 11% of vibe-coded apps are leaking Supabase keys, Hacker News— news.ycombinator.com

Related Posts

Mar 12, 2026·Security

Exposed API Keys: How AI Tools Leak Your Secrets

Claude Code CVEs, Google's $82K API key incident, 5,000+ repos leaking ChatGPT keys. Learn how AI tools expose your secrets and how to lock them down in Next.js.

Feb 23, 2026·Security

Server Actions + Zod in Next.js 16: Validate Every Input

Server Actions are public HTTP endpoints. Validate every payload with Zod before any database call. Patterns for Next.js 16 and Zod 4 with CVE context.

May 21, 2026·Security

Supabase OAuth, Magic Links, MFA in Next.js [2026]

Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.