← All Articles 🔒 Security Vulnerabilities Critical By Nishant Kumar Verma Updated

Client-Side Secret Exposure: NEXT_PUBLIC_ and VITE_ Env Var Dangers

Prefixing secrets with NEXT_PUBLIC_ or VITE_ exposes them in the browser bundle. Learn how AI tools cause this critical mistake.

SEC-006

Quick Answer

Environment variables prefixed with NEXT_PUBLIC_ in Next.js or VITE_ in Vite are automatically bundled into the JavaScript code that browsers download. Any secret - API key, Stripe key, service role key - given one of these prefixes is no longer secret. It can be read by anyone who opens DevTools and searches the page source. AI tools create this mistake when they conflate "the frontend needs this value" with "this value is safe to expose."

How Next.js and Vite Environment Variable Exposure Works

Next.js and Vite have two categories of environment variables: server-only and public. Server-only variables (DATABASE_URL, STRIPE_SECRET_KEY, OPENAI_API_KEY) are available only in server-side code - API routes, Server Components, getServerSideProps - and never included in browser bundles. Public variables (NEXT_PUBLIC_* and VITE_*) are inlined at build time into the JavaScript the browser loads.

The replacement is literal. When you write process.env.NEXT_PUBLIC_STRIPE_KEY in a React component, Next.js replaces that expression with the actual value of the variable during the build - the string "pk_live_abc123xyz" is embedded directly in a file under _next/static/chunks/. Anyone who visits the page can find it with DevTools.

According to GitGuardian's State of Secrets Sprawl 2024, 12.8 million new secrets were leaked in public GitHub repositories in 2023, including a 1212x surge in leaked OpenAI API keys. A secret behind a public env prefix does not even need a commit to leak: it ships to every visitor in the build output.

The Pattern AI Tools Generate

// ❌ BAD - Secret key exposed in browser bundle
// .env.local (or .env)
NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# ← CRITICAL: secret key
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Abc123...
# ← CRITICAL: can bill your account
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-ABC...

// components/Checkout.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
// ↑ This key is now in your browser bundle and visible to anyone

The Supabase anon key is deliberately designed to be public - it is safe to prefix with NEXT_PUBLIC_. But the Stripe secret key, OpenAI API key, any service role key, database connection string, or internal signing secret must never receive that prefix. AI tools make this mistake because they see "the component needs this value" and add the prefix to make it available, without understanding the exposure boundary.

The Correct Architecture: Proxy Through Server Routes

// ✅ GOOD - Secret stays on the server; frontend calls an API route
// .env.local
# No NEXT_PUBLIC_ prefix
STRIPE_SECRET_KEY=sk_live_51Abc123...
OPENAI_API_KEY=sk-proj-ABC...

// app/api/checkout/route.ts (Server-side - secret is safe here)
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const authSession = await getServerSession(authOptions);
  if (!authSession) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const userId = authSession.user.id; // from the server session, never the request body
  const { priceId } = await req.json();
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cancel`,
    metadata: { userId },
  });
  return Response.json({ url: session.url });
}

// components/Checkout.tsx (Client-side - no secret here)
async function handleCheckout(priceId: string) {
  const res = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ priceId }),
  });
  const { url } = await res.json();
  window.location.href = url;
}

The rule is: secrets live on the server; the browser gets only what it needs to display UI. A Next.js API route or Server Component has access to server-only environment variables and can make the external API call, returning only the safe subset of data to the client.

Which Variables Are Safe to Make Public

Variable Safe with NEXT_PUBLIC_ ? Reason
Supabase URL + anon key Yes Designed to be public; Row Level Security enforces access
Stripe publishable key (pk_live_*) Yes Intended for browser use; cannot charge cards alone
App URL / base domain Yes Not sensitive; needed for frontend redirects
Google Analytics measurement ID Yes Public by design; visible in page source
Stripe secret key (sk_live_*) No - Critical Full API access; can refund, delete, retrieve all data
OpenAI API key No - Critical Billed to your account; no rate limit per caller
Supabase service role key No - Critical Bypasses all RLS policies; full database access
Database connection string No - Critical Direct database access from any machine
JWT signing secret No - Critical Allows forging valid auth tokens

How to Check Your Bundle for Exposed Secrets

The fastest manual check is to build your app and search the output. Run npm run build and then search the contents of .next/static/chunks/ for known secret patterns: sk_live_, sk-proj-, service_role, eyJhbGci (base64 JWT header). If any of these appear in that directory, the key is in your browser bundle.

For Vite projects, the built dist/assets/ directory contains the bundled JavaScript. The same search applies. You can also open the deployed application in a browser, open DevTools, go to the Sources tab, and search for the key string - if it appears in any loaded script, it is exposed to every visitor.

Secret exposure in client-side code is easy to miss, because developers assume environment variables are private. The prefix rules are framework-specific and not intuitive. This is especially prevalent in AI-generated Next.js code where the model has seen both patterns in training data and cannot distinguish safe from unsafe.

Rotating an Exposed Secret

If you discover a secret has been in a public env var, treat it as compromised even if you do not see evidence of misuse. Rotate it immediately: generate a new Stripe key in the Stripe dashboard, regenerate the Supabase service role key, create a new OpenAI API key, and revoke the old ones. Then audit git history - if the .env.local file or a build artifact containing the key was ever committed, the value may persist in commit history even after the file is removed.

Tools like VibeDoctor's Vibe Check automatically flag code that reads a public-prefixed variable whose name marks it as a secret (for example process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY or a VITE_ variable ending in PASSWORD), with specific file paths and line numbers, and run Gitleaks over every file in the repository to catch the secret values themselves. A variable named only ..._API_KEY is not flagged by name, so check those yourself. Free to sign up.

Related reading: Google AI Studio vibe coding.

FAQ

Does Vercel's environment variable UI protect secrets from being public?

No. Vercel's settings choose which environments get a variable (Production, Preview, Development) and whether its value stays readable in the dashboard (Config) or becomes write-only (Secret). None of them controls whether the value reaches the browser. If the name starts with NEXT_PUBLIC_, Next.js inlines it into the client bundle at build time, whatever the dashboard says. Always check the variable name prefix.

The Supabase anon key is in my bundle - is that a problem?

No, if Row Level Security is correctly configured. The Supabase anon key is intentionally designed to be used in browser-side JavaScript. It identifies your project but cannot bypass RLS policies. The dangerous one is the service_role key, which bypasses all RLS rules - that key must never leave the server.

What about VITE_ prefix in Vite/React projects?

Vite uses the VITE_ prefix the same way Next.js uses NEXT_PUBLIC_. Any variable starting with VITE_ is exposed in client-side source code after Vite bundles it. Variables without the prefix are kept out of the client bundle. The same rule applies: never prefix a secret with VITE_.

Can I call OpenAI directly from the browser safely?

Not with your own API key, because exposing it allows anyone to make calls billed to your account. The correct pattern is a Next.js API route or Supabase Edge Function that holds the OpenAI key server-side, applies any rate limiting and auth checks you need, and proxies the request. Never pass the OpenAI key to the browser under any circumstances.

How do I check if a past deployment had exposed secrets?

Download your Vercel or Netlify deployment artifacts if available, or access an archived version of the deployed app and search the JavaScript bundles. Check your git history for any .env files that were committed. Services like GitGuardian scan public repositories continuously, so assume anyone could already have found a publicly committed secret. Rotate any key that may have been exposed and audit access logs in the respective service dashboards.

Diagnose your codebase - free

VibeDoctor checks for SEC-006 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →