One of the most common mistakes in AI-built apps is a secret API key with a NEXT_PUBLIC_ (or VITE_) prefix. It usually happens for an understandable reason: the code didn't work until the prefix was added. Unfortunately, adding the prefix is exactly what made the key public.
This guide explains what those prefixes do, which keys are fine to expose, how to check whether you've leaked one, and how to fix it properly.
What the prefix actually does
In Next.js, environment variables are only available on the server by default. If a variable's name starts with NEXT_PUBLIC_, Next.js inlines its value into the JavaScript bundle at build time, so browser code can read it.
That means the value is literally written into a .js file that every visitor downloads. Anyone can find it by opening developer tools, viewing the page source, or searching the files under /_next/static/. Minification doesn't hide it.
Vite does the same thing with VITE_: variables with that prefix are exposed on import.meta.env in client code and embedded in the built bundle. Create React App used REACT_APP_, and Expo uses EXPO_PUBLIC_, with the same effect.
There's no such thing as a "hidden" public env var. If the browser can read it, so can everyone.
Which keys are safe to be public
Some services issue keys designed to be shipped to the browser. Their security comes from other controls, not from secrecy.
Generally safe in client code:
- Supabase project URL and anon / publishable key (safe only if RLS is enabled on your tables; see the RLS checklist)
- Stripe publishable key (
pk_test_.../pk_live_...) - Firebase web config (protected by Security Rules)
- Public analytics or error-tracking IDs (e.g. a PostHog project key or Sentry DSN)
- Map or search keys restricted by HTTP referrer or with search-only scopes
Never in client code:
- Supabase
service_role/ secret key (bypasses all RLS) - Stripe secret key (
sk_...) or restricted keys (rk_...) - Stripe webhook signing secret (
whsec_...) - OpenAI, Anthropic, Google AI, Replicate or other model API keys
- Email/SMS keys: Resend, SendGrid, Postmark, Twilio
- Database connection strings, JWT signing secrets,
NEXTAUTH_SECRET/AUTH_SECRET - GitHub tokens, AWS keys, any OAuth client secret
A quick rule: if the provider's docs call it a "secret" or say "keep it on your server," it must never have a public prefix.
How to check if you've leaked one
1. Look at your env files and code.
grep -rnE "(NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_[A-Z0-9_]*(SECRET|SERVICE_ROLE|OPENAI|ANTHROPIC|PRIVATE|TOKEN)" \
--include="*.ts" --include="*.tsx" --include="*.js" --include="*.env*" .
Names like NEXT_PUBLIC_OPENAI_API_KEY or VITE_SUPABASE_SERVICE_ROLE_KEY are red flags on their own.
2. Look at the built output. Build locally and search the bundle for known key prefixes:
npm run build
grep -rnoE "sk_live_[A-Za-z0-9]+|sk-[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+" .next/static | head
(A Supabase anon key is also a JWT starting with eyJ, so check the role claim of any match. anon is expected; service_role is a leak.)
3. Check git history and committed .env files. A .env file that was committed once is still in history even if it's deleted now:
git log --all --oneline -- .env .env.local .env.production
Tools like gitleaks scan your entire history for hundreds of key formats. PreflightPR runs gitleaks as part of every scan.
How to fix a leaked key
Step 1: Rotate it. Go to the provider's dashboard and revoke or roll the key. This is the step that actually stops misuse. Removing the key from code does nothing for copies already downloaded, cached or scraped from your repo.
Step 2: Remove the public prefix and move the call to the server.
Before (the key ships to every visitor):
"use client";
export async function summarize(text: string) {
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`, // ❌
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] }),
});
return r.json();
}
After (the key stays on the server; the browser calls your own route):
// app/api/summarize/route.ts
import { auth } from "@/auth"; // your auth helper
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { text } = await req.json();
if (typeof text !== "string" || text.length > 5000) {
return new Response("Bad request", { status: 400 });
}
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // ✅ server-only
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] }),
});
return Response.json(await r.json());
}
Notice the extra lines: an auth check and input limits. Moving a key server-side but leaving the route open to anyone just turns your route into a free proxy for strangers.
For Vite or Lovable apps with no server, put the call in a Supabase Edge Function (or any serverless function) and store the key as a secret there.
Step 3: Guard against regressions. Add .env* (except .env.example) to .gitignore. In Next.js, you can import the server-only package in modules that read secrets, so the build fails if client code imports them:
// lib/openai.ts
import "server-only";
export const openaiKey = process.env.OPENAI_API_KEY!;
Step 4: Set limits at the provider. Spending caps, usage alerts and, where supported, IP or referrer restrictions limit the damage if a key leaks again.
Why this keeps happening
AI coding tools often generate a working demo first. When a server-only variable is undefined in a client component, the quickest "fix" is to add NEXT_PUBLIC_, and the error goes away. The app works, so nobody looks again. In our own deliberately vulnerable sample app, leaked and client-exposed keys were among the 12 critical issues PreflightPR flagged.
Check your repo
Run a free PreflightPR scan on a public repo to see whether secrets, committed .env files or client-exposed keys show up, along with Supabase RLS and Stripe webhook checks. It's an automated best-effort review, not a penetration test, and it's a fast way to catch this before your users do.