Public env variables: what ends up in the browser
Any environment variable with a public prefix (NEXT_PUBLIC_ in Next.js, VITE_ in Vite, PUBLIC_ in SvelteKit and Astro, REACT_APP_ in Create React App, EXPO_PUBLIC_ in Expo) is copied into the JavaScript your visitors download. Publishable keys are fine there. Secret keys must stay unprefixed, be read only in server code, and be rotated if they ever shipped.
Updated
Check your live site now
Free, no signup, read only. A grade and plain fixes in seconds.
How build tools put env variables into the bundle
Your .env file lives on your machine or your host. The browser never sees it. When you build, the bundler looks for references to certain variables in your front end code and replaces each one with its literal value. After the build, process.env.NEXT_PUBLIC_SUPABASE_URL is no longer a variable. It is the string https://abc.supabase.co, sitting in a JavaScript file that anyone can download.
Each framework uses a prefix to decide which variables get this treatment. Variables without the prefix are kept out of client code.
- Next.js: NEXT_PUBLIC_. Read with process.env.NEXT_PUBLIC_NAME. Unprefixed variables are available in server components, route handlers and server actions, and come out undefined in client components.
- Vite (and React, Vue or Svelte apps built with it): VITE_. Read with import.meta.env.VITE_NAME.
- SvelteKit: PUBLIC_. Read from $env/static/public or $env/dynamic/public. Private values come from $env/static/private, and SvelteKit refuses to build if client code imports that module.
- Astro: PUBLIC_. Read with import.meta.env.PUBLIC_NAME. Unprefixed variables are only available in server code.
- Create React App: REACT_APP_. Embedded at build time. The project is deprecated but plenty of apps still use it.
- Expo: EXPO_PUBLIC_. Inlined into the app's JavaScript bundle, which ships inside the app on every user's phone.
Values are frozen at build time. If you change NEXT_PUBLIC_API_URL on your host, nothing changes until you rebuild and redeploy.
Anything in the JavaScript bundle is public
Minified code is not hidden code. Anyone can open DevTools, go to the Sources or Network tab, and search your JavaScript for sk_ or Bearer. Automated scanners search for known key formats too. If source maps are published, your original code is readable too. For a mobile app, the bundle can be pulled out of the installed app.
So the question for each variable is simple: would you be fine with a stranger reading this value? If yes, a public prefix is fine. If no, it must not have one.
Which values are safe to expose in the browser
These are designed to be public. Their providers expect them in front end code and protect your account some other way:
- Your Supabase project URL and anon or publishable key (sb_publishable_), as long as row level security is on for your tables.
- Stripe publishable keys (pk_live_ and pk_test_).
- Firebase web config, including its apiKey, as long as your security rules are locked down.
- Google Maps and other Google browser keys, when restricted to your domains in Google Cloud console.
- Analytics and error tracking IDs, such as a Google Analytics measurement ID or a Sentry DSN.
- CAPTCHA site keys, such as Cloudflare Turnstile or reCAPTCHA site keys. The matching secret key is not public.
- Your own API's base URL and feature flags.
Which values must never get a public prefix
These give whoever holds them the power to spend your money, read your data or act as your app:
- Stripe secret and restricted keys (sk_live_, rk_live_) and webhook signing secrets.
- AI provider keys: OpenAI (sk-, sk-proj-), Anthropic (sk-ant-) and similar. A leaked one gets used on your bill.
- The Supabase service_role key or any sb_secret_ key. These bypass row level security.
- Database connection strings (postgres://user:password@...).
- Cloud credentials such as AWS access keys, and any private key or service account JSON file.
- Email and messaging API keys (SendGrid, Resend, Twilio, Slack tokens), GitHub tokens, and your own JWT or session signing secrets.
A pattern to watch for: the app needs to call OpenAI from a React component, the unprefixed variable comes out undefined, so the agent renames it to VITE_OPENAI_API_KEY and it works. It works because the key is now public. If you see a secret gain a public prefix in a diff, stop and move the call server side instead.
Other ways secrets end up in client code
- The env option in next.config inlines its values into the bundle regardless of prefix.
- Vite's define option does the same. Calling loadEnv in vite.config with an empty prefix and passing the result to define exposes every variable.
- Hardcoded keys. An agent pastes the key straight into a file to get a feature working. Search your source for key prefixes, not just your .env.
- Server modules imported by client components. In Next.js, add import "server-only" at the top of any module that reads secrets, so the build fails if client code imports it.
- Static files. A config.json or .env file dropped into public/ is served as is, to anyone who requests it.
How to move a secret key server side
The fix is always the same shape. The browser calls your own endpoint, your endpoint holds the secret and calls the provider, and only the result goes back to the browser.
- Rename the variable to drop the public prefix, for example OPENAI_API_KEY, and set it in your host's environment settings.
- Create a server endpoint that reads it. In Next.js that is a route handler or server action. With a Vite, Create React App or Expo front end, use a serverless function on your host (Vercel, Netlify, Cloudflare Workers) or a Supabase Edge Function.
- Change the front end to call your endpoint instead of the provider.
- Protect the endpoint. It now spends your money on request, so check the user is signed in and add a rate limit.
- Rebuild, redeploy, and confirm the key is gone from the new bundle.
// lib/ai.ts: only server code may import this file
import "server-only";
export async function summarize(text: string) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OPENAI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "YOUR_MODEL",
messages: [{ role: "user", content: "Summarize: " + text }],
}),
});
if (!res.ok) throw new Error("Upstream error " + res.status);
const data = await res.json();
return data.choices[0].message.content as string;
}
// app/api/summarize/route.ts
import { summarize } from "@/lib/ai";
export async function POST(req: Request) {
// Check the session and rate limit here before spending money
const { text } = await req.json();
return Response.json({ summary: await summarize(text) });
}If a secret already shipped, rotate it
Removing the key from your code does not un-leak it. Old builds may still be cached by your CDN or saved in web archives, earlier deploys may still be reachable, and anyone who already copied the key still has it. Assume it is compromised.
- Create a new key in the provider's dashboard and put it in your server environment.
- Deploy the server side version that uses the new key.
- Revoke or delete the old key.
- Check the provider's usage or billing page for activity you do not recognize.
- If the key is also in your git history, treat the repository as exposed too.
How to check your own build for leaked secrets
Build locally and search the output folder for known secret prefixes. Better still, search for the first 10 or so characters of each actual secret value from your .env file.
# Next.js
grep -rE "sk_live_|rk_live_|sk-proj-|sk-ant-|sb_secret_|BEGIN .*PRIVATE KEY" .next/static
# Vite and Astro (dist), Create React App (build), SvelteKit (.svelte-kit/output/client)
grep -rE "sk_live_|rk_live_|sk-proj-|sk-ant-|sb_secret_|BEGIN .*PRIVATE KEY" distOur scan does the same from the outside: it reads your live page and the site's own JavaScript bundles, and flags secret key formats such as Stripe, OpenAI, Anthropic, AWS, GitHub and Supabase service keys, plus Google API keys that have no restrictions. It also checks whether /.env or /.git is downloadable.
Questions
Are NEXT_PUBLIC_ variables secure?
They are public by definition. Next.js copies their values into the JavaScript sent to every visitor. Use them only for values you would be happy to publish, such as a Supabase anon key or a Stripe publishable key.
Why is my Vite env variable undefined?
Vite only exposes variables that start with VITE_ to client code, and you read them with import.meta.env, not process.env. If the value is a secret, the right fix is a server endpoint, not adding the prefix.
Does putting a key in .env keep it secret?
Only if nothing copies it into the bundle. The .env file keeps the key out of your source code, but a public prefix, a define or env entry in your build config, or a hardcoded copy will still ship it to browsers.
Is it safe if the key is only in a private GitHub repo?
The repo being private does not matter once the value is in your built JavaScript. The live site is public, so the key is too.
Can I hide a secret by encoding or obfuscating it in the front end?
No. The browser has to decode it to use it, so anyone can do the same. A secret that the browser can use is not a secret.