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 statically replaced at build time inside every JavaScript file 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 _next/static/chunks/main.js. Anyone who visits the page can find it with Ctrl+U or DevTools.
According to GitGuardian's 2024 State of Secrets Sprawl report, over 12.8 million secrets were detected in public GitHub repositories in a single year, with API keys from Stripe, OpenAI, and Supabase among the most frequently exposed types. A significant fraction of these reach GitHub because a developer used a public env prefix, the value appeared in client-side code, and the build artifact or source was committed.
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...
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Abc123... // ← CRITICAL: secret key
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-ABC... // ← CRITICAL: can bill your account
// 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
STRIPE_SECRET_KEY=sk_live_51Abc123... // No NEXT_PUBLIC_ prefix
OPENAI_API_KEY=sk-proj-ABC... // No NEXT_PUBLIC_ prefix
// app/api/checkout/route.ts (Server-side - secret is safe here)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10',
});
export async function POST(req: Request) {
const { priceId, userId } = 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',
body: JSON.stringify({ priceId, userId: session.user.id }),
});
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.
A 2023 Veracode analysis found that secret exposure in client-side code is underreported because developers assume environment variables are private - the prefix semantics 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 (vibedoctor.io) automatically scan your codebase for secrets exposed via client-side environment variable prefixes and flag specific file paths and line numbers. Free to sign up.
FAQ
Does Vercel's environment variable UI protect secrets from being public?
Vercel lets you mark variables as "Server" (not exposed to browser), "Preview", or "All" (which includes the browser). But the actual enforcement happens at the framework level - if you add NEXT_PUBLIC_ to a variable name, Next.js will bundle it regardless of the Vercel dashboard setting. The dashboard label is documentation, not enforcement. 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 statically replaced in the browser bundle at build time. Variables without the prefix are only available in Node.js contexts like vite.config.ts. 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 and will have already flagged any publicly committed secret. Rotate any key that may have been exposed and audit access logs in the respective service dashboards.