How to Hide API Keys in Vite/React
Short version: you can't hide a secret in frontend code. If your React app can read it, so can anyone who opens DevTools. The fix is to keep the secret somewhere the browser never sees, and let the browser ask that place to do the work.
TL;DR
VITE_variables are baked into your JavaScript at build time. That's fine for public values (your Supabase URL and anon/publishable key, a Stripepk_key). Secret keys (OpenAI, Stripesk_, Supabaseservice_role/sb_secret_) go in a server, edge function or proxy. If one already shipped, rotate it.
1. Why VITE_ variables aren't secret
Vite only exposes env variables that start with VITE_ to your client code, via import.meta.env. And it doesn't look them up at runtime: it replaces them with their actual values when you build. So the string ends up in a .js file that every visitor downloads.
# .env VITE_SUPABASE_URL=https://xyz.supabase.co # exposed to the client OPENAI_API_KEY=sk-... # not exposed to client code // in your React code import.meta.env.VITE_SUPABASE_URL // "https://xyz.supabase.co" import.meta.env.OPENAI_API_KEY // undefined
Vite's own docs say VITE_* variables shouldn't contain sensitive information like API keys, and suggest a backend server or serverless/edge functions instead. Minifying or obfuscating doesn't change this; a key is still a plain string in the bundle. Don't widen envPrefix in your Vite config to sneak other variables in, either. That just exposes more.
Dropping the prefix doesn't magically make a key usable, by the way. OPENAI_API_KEY above is undefined in the browser. Code that needs it has to run on a server.
2. Sort your keys: public or secret?
| Fine in the browser | Server only |
|---|---|
Supabase URL and anon / sb_publishable_ key (with RLS on) | Supabase service_role / sb_secret_ key |
Stripe publishable key (pk_) | Stripe secret key (sk_) and webhook secrets |
| Keys a provider explicitly designs for browsers, locked down with its own restrictions | OpenAI, Anthropic and most other AI API keys; database URLs; email/SMS provider keys |
Rule of thumb: if the provider's docs call it a "secret" key, or using it costs you money or bypasses permissions, it goes on a server. The anon key is only safe because of Row Level Security, so make sure that's actually set up: the Supabase RLS checklist.
3. Option A: a Supabase Edge Function
If you already use Supabase (most Lovable and Bolt apps do), this is usually the easiest path. Store the secret as an Edge Function secret, then call the function from React.
supabase secrets set OPENAI_API_KEY=sk-...
// supabase/functions/ask-ai/index.ts (sketch)
const corsHeaders = {
"Access-Control-Allow-Origin": "https://your-app.com", // your app's origin
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
// browsers send a CORS preflight before supabase.functions.invoke()
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// 1. verify the caller (check the Authorization header / user session)
// 2. validate and limit the input, and rate-limit per user
const { prompt } = await req.json();
const r = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "<model>", input: prompt }),
});
if (!r.ok) {
// don't pass raw upstream errors back to the browser
return new Response(JSON.stringify({ error: "AI request failed" }), {
status: 502,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(await r.text(), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});// React: the browser only ever talks to your function
const { data, error } = await supabase.functions.invoke("ask-ai", {
body: { prompt },
});Edge Functions read secrets with Deno.env.get(). Locally they come from supabase/functions/.env (keep it in .gitignore). Supabase also injects its own keys, like SUPABASE_URL and the secret/service_role key, so you never need to put those anywhere near your frontend. You can't name your own secrets with a SUPABASE_ prefix; that's reserved. Calls from your React app are cross-origin, so the function has to answer the OPTIONS preflight and send CORS headers on every response, as above. Edge Functions verify a Supabase JWT by default, but the anon key alone passes that check, so still confirm there's a real signed-in user before spending your API credits.
4. Option B: a serverless function on your host
Deploying to Vercel or Netlify? Their serverless functions work the same way: put the secret in the host's environment variables without the VITE_ prefix, read it with process.env inside the function, and have React fetch your own endpoint (for example /api/ask-ai). File locations and setup differ by host, so follow their docs for the details.
5. Option C: a small proxy, and the dev-proxy trap
Any backend you control (Express, Hono, a Cloudflare Worker) can act as a proxy: it receives the request from your app, adds the secret, forwards it to the API, and returns the result.
Careful with Vite's server.proxy setting. It only runs in the dev server (vite / npm run dev), not in a production build, so it can't hide a key in production. vite build produces static files, and that proxy doesn't exist in production. Putting a key in your proxy config hides it locally and then breaks (or tempts you to move the key back into the frontend) once you deploy.
6. Don't build an open proxy
Moving the key to a server stops it leaking, but anyone can still call your endpoint. Before you ship it:
- Require a signed-in user and check their session on the server.
- Only allow the specific action your app needs (no "forward any URL / any body" endpoints).
- Validate input size, and add rate limits or per-user quotas.
- Set spending limits or alerts in the provider dashboard where they exist.
- Don't return the secret or raw error details in responses.
7. Already shipped a key? Rotate it
Removing it from the code isn't enough. Old deploys, caches and git history keep it around. Create a new key in the provider's dashboard, put it on your server, redeploy, then revoke the old one. Check git too: if a .env file was ever committed, treat everything in it as leaked. Vite's docs recommend adding *.local to .gitignore; keep secrets in .env.local files and make sure any .env with real secrets is ignored too.
Leaked a Supabase service_role key specifically? Here's the step-by-step. Not sure what's in your bundle? How to find leaked API keys in your JavaScript.
Copy-paste prompt for Lovable, Bolt, Cursor or Claude
Audit this Vite + React project for secrets exposed to the browser. List every import.meta.env.VITE_* variable and every hardcoded key, and classify each as public (e.g. Supabase URL, anon/publishable key, Stripe pk_) or secret (e.g. OpenAI/AI keys, Stripe sk_, Supabase service_role/sb_secret_, database URLs). For each secret: remove it from client code and from any VITE_ variable, move the API call into a Supabase Edge Function (secret stored with supabase secrets set and read via Deno.env.get), verify the caller's session in the function, validate input, and call it from React with supabase.functions.invoke. Don't rely on Vite's server.proxy for production. Make sure .env files with secrets are in .gitignore. Finally, list which keys I need to rotate because they were already shipped.
What ChillScan can and can't see
ChillScan checks your live page's HTML and the first-party JavaScript files it loads (without running them), plus robots.txt and sitemap.xml, and flags key patterns it recognizes, like Supabase service_role / sb_secret_ keys. It can't see your source code, server config, edge functions, env variables or git history, so it can't confirm a key was rotated or that your proxy is locked down. Keys that only load after the app runs, or in lazily loaded files, can be missed, so a quick DevTools search is still worth doing.
Moved your keys? Double-check the bundle
Paste your URL and the free scan checks your public code for leaked keys, plus headers, HTTPS, SEO and speed. No signup. The $12 deep scan gives you every finding with severity ratings, explanations, fix tips and evidence, plus a copy-paste fix prompt for Lovable, Bolt, Cursor or Claude to work through the list.
Built on Lovable? Lovable security checklist →