VITE_ env variables are visible in your build. What now?
Vite writes every VITE_ variable into the JavaScript your visitors download. How to see what your Lovable, Bolt or Vite app ships, and move secrets off the client.
· 7 min read
You open DevTools on your live site, search the scripts for the name of an API key, and there it is: the full value, sitting in assets/index-a1b2c3.js. You never typed it into a component. It came from .env, through a variable called VITE_OPENAI_API_KEY or VITE_STRIPE_SECRET.
This is not a Vite bug, and hiding it better in your hosting dashboard won't help. It is how the prefix is designed to work. Apps built with Lovable, Bolt and most React starters use Vite, so this applies to a large share of AI-built frontends. Below: what the prefix does, the less obvious ways a value ends up in the bundle, how to check your own build and live site, and how to fix it.
What VITE_ actually means
The Vite documentation on env variables is direct about it: variables prefixed with VITE_ are exposed in client-side source code after bundling, and they should not contain sensitive information such as API keys.
A few details matter when you clean up.
- Values are replaced at build time. Every
import.meta.env.VITE_SOMETHINGin your code becomes the literal string duringvite build. The result is a static file indist/assets/. Minification renames your functions but leaves string values readable. - Changing the dashboard does not change old builds. The value is baked into files that were already deployed. You need a new build, and previous deployments or preview URLs may keep serving the old files.
- Unprefixed variables stay out, unless you pull them in.
DB_PASSWORDin.envis not exposed to client code by default. That default is what people break when they try to fix anundefined. - Several files feed the build. Vite loads
.env,.env.local,.env.[mode]and.env.[mode].local, with mode-specific files taking precedence, and variables already set in the shell or CI win over all of them. A secret can come from any of these, including a file you forgot existed.
Not every VITE_ variable is a problem. A Supabase URL and anon or publishable key, a Stripe publishable key pk_live_…, an analytics ID or a Sentry DSN are designed to be public. The test is simple: could a stranger use this value to spend your money or read data that isn't theirs? If yes, it must not be in the browser. The anon vs service_role article covers the Supabase case.
How secrets get into a Vite bundle
AI assistants fix the error in front of them. When a component reads import.meta.env.OPENAI_API_KEY and gets undefined, there are several "fixes" that make it work and all of them publish the key.
- Adding the prefix.
OPENAI_API_KEYbecomesVITE_OPENAI_API_KEY. It works immediately, which is why it happens so often. - Changing
envPrefix. The config option controls which prefix is exposed. Setting it to something broad, or adding a second prefix that matches your secret's name, exposes more than intended. Vite refuses an empty prefix and throws an error, as the envPrefix docs explain, but a prefix likeAPP_orAPI_can still catch half your.env. definewith the whole environment. A common snippet invite.config.tsloads every variable withloadEnv(mode, process.cwd(), '')(the empty third argument means "ignore the prefix") and then passes it throughdefine, for example as'process.env': env. Everything in.envthen gets inlined wherever the code references it. Loading all variables in the config is fine for config decisions; passing them todefineis the leak.- Calling a paid API from the browser by design. Even with a correctly named variable, if the frontend calls OpenAI, Resend or Stripe directly, the key has to be in the browser. No naming trick avoids that. The call needs to move to a server.
Check it yourself
Do all three. The code tells you what could leak; the build and the live site tell you what did.
1. List what the code exposes
From the project root:
grep -rhoE 'VITE_[A-Z0-9_]+' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='.env*' . \
| sort -u
grep -nE 'envPrefix|define|loadEnv' vite.config.*
Read the first list for names with SECRET, PRIVATE, SERVICE_ROLE, TOKEN, PASSWORD, or a provider name next to KEY. In the config output, look at what define receives. If it is the whole loaded environment or process.env, treat every variable as public.
Then open your hosting dashboard (Vercel, Netlify, Lovable's project settings or wherever you build) and read its variable list too. Names set there by hand may not appear in the repository at all.
2. Search a production build
npm run build
grep -rlE 'sk_live_|rk_live_|sk-proj-|sk-ant-|sb_secret_|service_role|AKIA[0-9A-Z]{16}|ghp_|github_pat_|BEGIN PRIVATE KEY' dist
Any file it prints is a file the browser can download. For secrets without a recognizable prefix, such as a database password or a webhook secret, search dist for the first eight or ten characters of the real value. If your build emits source maps (build.sourcemap in the config), they are in dist as .map files and get searched too.
3. Search the live site
Production may have been built with different variables than your laptop. Open the site in Chrome, open DevTools, tick "Disable cache" on the Network tab and reload. Click through the pages that use the feature you are worried about, so lazy-loaded chunks load as well. Then press Cmd+Option+F (Mac) or Ctrl+Shift+F (Windows) and search every loaded file for the same prefixes.
The guide to finding API keys in a JavaScript bundle goes deeper on chunks and source maps.
Fix it: revoke first, then move the call
Order matters here.
- Revoke or roll the key at the provider. It has already been served to every visitor and may be cached by browsers, CDNs and crawlers. Removing it from the code does not un-publish it. For an OpenAI key, the OpenAI leak checklist covers checking usage.
- Remove the prefix from the variable name in every
.env*file and in every hosting environment, including preview. - Move the call to a server. With Lovable or Bolt on Supabase, that usually means a Supabase Edge Function that holds the key as a function secret; on Vercel or Netlify, a serverless function. The browser calls your function; the function calls the provider.
- Fix the config. Remove any
defineentry that passes the loaded environment orprocess.env. KeepenvPrefixat the defaultVITE_unless you have a clear reason. - Protect the new endpoint. If the feature is for logged-in users, check the session in the function. Otherwise anyone can call your function and spend your credits just as they would with the key.
- Rebuild, redeploy, and repeat steps 2 and 3 of the check against the live site.
A prompt for Lovable, Cursor or Claude Code:
A secret is exposed to the browser through the Vite variable
VITE_[NAME]. I have already revoked the old value.
1. Find every place the frontend reads VITE_[NAME] and every call it
makes to [PROVIDER] from the browser.
2. Move those calls into a server function (a Supabase Edge Function
if the project uses Supabase, otherwise a serverless function on
our host). Read the key there as [NAME], with no VITE_ prefix, from
the function's secrets.
3. Change the frontend to call that function and receive only the
result. If the feature is for signed-in users, verify the user's
session inside the function before calling [PROVIDER].
4. In vite.config, remove any define entry that passes process.env or
the whole loadEnv result to the client, and keep envPrefix as the
default.
5. Run the production build and search dist for "[NAME]" and for the
provider's key prefix. Show me the output.
List every file you changed and every secret I need to set by hand.
Read the diff before you deploy. Assistants sometimes keep a fallback that still reads the old variable "for local development", which brings the key straight back.
Keep it from coming back
The next "it says undefined, please fix" request can undo all of this in one line. Two habits help.
Allowlist public names in CI. A short check fails the build when a new VITE_ name appears that nobody approved:
ALLOWED='^VITE_(SUPABASE_URL|SUPABASE_ANON_KEY|STRIPE_PUBLISHABLE_KEY|SITE_URL)$'
grep -rhoE 'VITE_[A-Z0-9_]+' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' src \
| sort -u | grep -vE "$ALLOWED" && { echo "Unexpected VITE_ variable"; exit 1; } || true
Adjust the list to what your app really needs. Adding a public variable becomes a decision, not an accident.
Check the deployed site, not only the repository. CI cannot see a variable set by hand in a dashboard or a define that pulls in the environment at build time. This is what ScanYourSite's leaked key check does: the free check loads the HTML and scripts your live page serves and looks for secret key formats from Stripe, OpenAI, Anthropic, AWS, GitHub, Supabase, SendGrid, Twilio and others, showing each one redacted with the file it was found in and where to revoke it. Keys that are meant to be public, like the Supabase publishable key, are reported as notes rather than alarms. The free plan checks one site weekly; on Pro, every new deploy is checked shortly after it goes live, and after you verify the domain it also looks for exposed .env and .git files.
However you do it, run the live-site search after any release that touches environment variables or vite.config, not just on launch day.