Are VITE_ environment variables secret? (No. Here's why)
By StackSiren · Published
No. Any variable whose name starts with VITE_ is copied into your JavaScript bundle at build time, so every visitor can read it. Use VITE_ only for values that are meant to be public, and keep real secrets on a server, in a serverless function or in an edge function.
This trips up a lot of apps built with Bolt, Lovable and other tools that produce Vite + React projects. The .env file feels private, the variable name looks like configuration, and the app works. But the key ends up in a .js file that anyone can download.
What Vite actually does with VITE_ variables
Vite's documentation is explicit. Variables prefixed with VITE_ "will be exposed in client-side source code after Vite bundling", and VITE_* variables "should not contain sensitive information such as API keys" because "the values of these variables are bundled into your source code at build time."
So this code:
const openai = new OpenAI({ apiKey: import.meta.env.VITE_OPENAI_API_KEY, dangerouslyAllowBrowser: true });
becomes something like this in dist/assets/index-abc123.js:
const openai = new OpenAI({ apiKey: "sk-proj-...", dangerouslyAllowBrowser: true });
Minification doesn't hide it. The string is still there, in plain text.
Variables without the prefix are not exposed. Vite's own example: with VITE_SOME_KEY=123 and DB_PASSWORD=foobar in .env, import.meta.env.VITE_SOME_KEY is "123" in the browser, while import.meta.env.DB_PASSWORD is undefined. That's the protection, and the prefix is how you opt out of it.
Vite also refuses an empty envPrefix (''), because that would expose every variable. If someone changed envPrefix in vite.config.ts to something broader, check what it now matches.
Which values are fine as VITE_ variables?
Fine to expose (they're designed to be public):
- Supabase project URL and publishable (
sb_publishable_...) or legacy anon key, provided your RLS is right - Stripe publishable key (
pk_live_.../pk_test_...) - Firebase web config (security comes from Firebase rules)
- Public analytics IDs, Sentry DSNs, map tiles keys that are restricted by domain
Never expose:
- OpenAI, Anthropic, Gemini or other LLM API keys
- Supabase
service_roleorsb_secret_...keys - Stripe secret keys (
sk_...) and webhook secrets - Resend, SendGrid, Twilio or other email/SMS keys
- Database connection strings, JWT signing secrets, admin passwords
If a variable in the second list has a VITE_ prefix, assume it's already public.
Step 1: Check your own live build
You don't need special tools. On your own site:
- Open the site, open DevTools → Sources (or Debugger in Firefox), and look in
assets/. - Use the search across files (Ctrl/Cmd + Shift + F) for
sk-,sk_live,service_role,secret,apikeyandBearer.
Or from a terminal, against your own domain:
# List the JS files your homepage loads
curl -s https://yourapp.com/ | grep -oE '/assets/[^"]+\.js' | sort -u
# Search one of them for likely secrets
curl -s https://yourapp.com/assets/index-abc123.js \
| grep -oE '(sk-(proj-)?[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{10,}|sb_secret_[A-Za-z0-9_-]{10,}|re_[A-Za-z0-9_]{20,})' \
| sort -u
Also check whether your source maps are public, because they contain your original source, comments included: curl -sI https://yourapp.com/assets/index-abc123.js.map. A 200 means anyone can read your un-minified code. See the exposed source maps guide.
Our exposed API key checker and env file checker run these checks for you, passively and only on your own site.
Step 2: If a secret was exposed, rotate it first
Removing the variable and redeploying is not enough. Old bundles may be cached, archived or already copied. In this order:
- Revoke or roll the key in the provider's dashboard (OpenAI API keys page, Stripe → Developers → API keys, Supabase → Settings → API keys, and so on).
- Check usage and billing for activity you don't recognise.
- Set spending limits where the provider supports them.
- Then fix the code as below, and only put the new key in a server-side secret.
The exposed API keys guide has provider-by-provider steps.
Step 3: Move the secret behind a server
The pattern is always the same: the browser calls your endpoint, and your endpoint calls the provider with the secret.
Option A: Supabase Edge Function (common for Lovable and Bolt + Supabase)
Store the secret where only the function can read it:
supabase secrets set OPENAI_API_KEY=sk-proj-your-new-key
supabase/functions/chat/index.ts:
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
// Require a signed-in user so strangers can't spend your credits
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const { message } = await req.json();
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Authorization": `Bearer ${Deno.env.get("OPENAI_API_KEY")}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: String(message).slice(0, 4000) }] }),
});
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
});
The front end calls it with the user's session, and no secret ships to the browser:
const { data, error } = await supabase.functions.invoke("chat", { body: { message } });
Note that the SUPABASE_URL and SUPABASE_ANON_KEY names above are the defaults Supabase documents for hosted functions; check your project's function settings if they differ. Swap the model name for whichever you use.
Option B: Netlify Function (common when Bolt deploys to Netlify)
Set OPENAI_API_KEY (no prefix) in Netlify's environment variables, then create netlify/functions/chat.mts:
export default async (req: Request) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const { message } = await req.json();
const r = 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: "gpt-4o-mini", messages: [{ role: "user", content: String(message).slice(0, 4000) }] }),
});
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
};
export const config = { path: "/api/chat" };
The browser calls fetch("/api/chat", ...). Add authentication and rate limiting before launch, or anyone can use your endpoint as a free proxy.
Step 4: Rename and clean up
- Rename
VITE_OPENAI_API_KEYtoOPENAI_API_KEYin your server environment only, and delete it from the front-end.env. - Search the codebase for
import.meta.env.VITE_and review every match against the "never expose" list. - Make sure
.envand.env.*.localare in.gitignore, and check your deployed site doesn't serve a.envfile:curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.envshould not print200with your variables. See the exposed .env guide. - Rebuild and redeploy, then repeat Step 1.
A note on Bolt and Lovable specifics
Both tools generate Vite projects in many cases, so Vite's rules apply to whatever ends up in the browser build. How each tool stores secrets for its own backends (for example Lovable's Cloud secrets or a connected Supabase project's Edge Function secrets) changes over time, so check the tool's current docs. The test that never changes is Step 1: if the secret is in the built JavaScript, it's public.
Related reading
- Is NEXT_PUBLIC_ safe for API keys?
- I leaked my OpenAI API key. What now?
- Setting up Supabase RLS in a Lovable app
FAQ
Can I obfuscate or base64 the key instead? No. The browser has to decode it to use it, so anyone can too. Obfuscation only slows down honest people.
Is dangerouslyAllowBrowser: true ever OK?
Only with keys designed for the browser, such as short-lived tokens your server issues. With a normal secret key, the flag's name is the warning.
My repo is private. Does that help?
Not for VITE_ variables. They're in the deployed JavaScript, which is public regardless of the repo.
Is my Supabase anon key a leaked secret?
No, it's meant to be public, as long as RLS is set up properly. A service_role key is a different story. See the Supabase RLS guide.
How do I stop it happening again?
Add a pre-deploy check that greps your dist/ folder for secret patterns, and run a scheduled external scan. StackSiren's first scan is free; paid plans start at $29/mo for Solo and include scheduled rescans.
Fix guides for this topic
Platform checklists: Bolt · Lovable