What this means
Next.js documents that variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle at build time, replacing process.env references with the hard-coded value. Vite documents that VITE_ variables are exposed in client-side source code, and that they should not contain sensitive information such as API keys. So the value is in a file anyone can download. Public identifiers are fine there. Secret keys are not.
Why it happens
- The code ran in the browser and the variable came back undefined. Adding the prefix made the error go away, and AI tools suggest exactly that.
- The API call was written in a client component, so the key had to be available in the browser for the code to work.
- Confusion between a provider's two keys: a publishable key (made for browsers) and a secret key (made for servers).
- A single .env was copied from a tutorial where every variable had the prefix.
How to fix it
- 1Work out whether the value is truly secret. Safe in the browser: Supabase URL and publishable or anon key, Firebase web config, Stripe publishable key, analytics ids. Never in the browser: Supabase service_role or secret keys, Stripe secret keys, OpenAI or other AI provider keys, database URLs, JWT signing secrets, email and SMS provider keys.
- 2Rotate the secret at the provider. The old value has been public since the first deploy that contained it.
- 3Rename the variable without the prefix, in your .env files and in your hosting dashboard.
- 4Move the code that uses it into a server route, server action or edge function. The browser calls your route. Your route calls the provider.
- 5Protect the new route: require a signed-in user and add rate limiting, otherwise you have built a free proxy to a paid API.
- 6Rebuild and redeploy. The value is baked in at build time, so changing the variable alone does not clean the bundle already deployed.
- 7If any .env file with the key was committed, purge it from git history with git filter-repo after rotating.
NEXT_PUBLIC_OPENAI_API_KEY=sk-...
OPENAI_API_KEY=sk-...
export async function POST(request: Request) {
const user = await getSignedInUser(request);
if (!user) return new Response('Unauthorised', { status: 401 });
const { prompt } = await request.json();
const upstream = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: 'YOUR_MODEL', input: String(prompt).slice(0, 2000) }),
});
return new Response(upstream.body, { status: upstream.status });
}How to confirm the fix
- Build locally and search the output for the start of the old and new key values. Neither should appear in browser files.
- On your live site, open devtools, Sources, and use search across all files for the key's prefix (for example sk- or sb_secret_).
- List every public variable in your project and check each one against the safe list above.
- Call the provider with the old key. It should be rejected.
npm run build
grep -rIl 'FIRST_12_CHARS_OF_THE_KEY' .next/static dist build 2>/dev/null
grep -rhoE '(NEXT_PUBLIC|VITE)_[A-Z0-9_]+' src app .env* 2>/dev/null | sort -uFrequently asked questions
Can I hide the key by obfuscating or encoding it?
No. If the browser can use the key, a person can extract it from the code or watch it in the Network tab. The only fix is to keep it on a server.
Is it safe to put my Supabase anon key in NEXT_PUBLIC_?
Yes. It is designed to be public and depends on row-level security for protection. The service_role or secret key is the one that must stay on the server.
I removed the prefix but the key is still in my live bundle. Why?
Values are inlined at build time. Trigger a fresh build and deploy, and remember older bundles may stay cached for a while. Rotation is what makes those copies harmless.