VITE_ environment variables are public
A lot of people assume that anything in a .env file is secret. In a frontend project that is not true. Vite, which powers most Lovable and Bolt apps, takes every variable that starts with VITE_ and pastes its value directly into the JavaScript sent to the browser. Next.js does the same with NEXT_PUBLIC_, Expo with EXPO_PUBLIC_, and Create React App with REACT_APP_.
That is a deliberate feature, not a bug. It is how your frontend gets values like your Supabase URL. But it means a secret key in a VITE_ variable is published to every visitor. This guide explains exactly what happens, how to check your build, and where each kind of value should live.
What Vite actually does with VITE_ variables
At build time, Vite statically replaces import.meta.env.VITE_SOMETHING with the variable's value. Vite's documentation is explicit that only variables prefixed with VITE_ are exposed to client code, and warns that these should not contain sensitive information.
So this source:
const stripe = new Stripe(import.meta.env.VITE_STRIPE_SECRET_KEY);becomes, in the built file in dist/assets/index-abc123.js, something like:
const stripe = new Stripe("sk_live_51H...");There is no runtime lookup and nothing to hide. Anyone who loads the page can read the string. The same is true of process.env.NEXT_PUBLIC_* in Next.js client components.
Variables without the prefix are not exposed to client code by Vite, which is why moving a secret out of VITE_ and into server code is the fix. Simply removing the prefix while still using the value in browser code will just make it undefined there.
Safe vs unsafe values
| Value | Safe in a VITE_ / NEXT_PUBLIC_ variable? | Why |
|---|---|---|
| Supabase project URL | Yes | Public by design |
| Supabase anon / publishable key | Yes | Protected by Row Level Security |
| Firebase web config (apiKey, projectId) | Yes | Protected by Security Rules |
Stripe publishable key (pk_live_...) | Yes | Designed for browsers |
| Analytics IDs, Sentry DSN, Google Maps key restricted to your domain | Generally yes | Meant for browsers; restrict where possible |
| Supabase service_role / secret key | No | Bypasses all RLS |
Stripe secret key (sk_live_...) or webhook secret | No | Full control of your Stripe account |
| OpenAI, Anthropic, Gemini, Replicate keys | No | Anyone can spend your credits |
| Resend, SendGrid, Twilio, Postmark keys | No | Send email or SMS as you |
| Database connection strings, JWT secrets, GitHub tokens | No | Direct access to your systems |
The rule of thumb: if the provider calls it a "secret", "private", "server" or "admin" key, it must never have a public prefix.
How to check your app right now
Search your code and env files:
grep -rnE "(VITE|NEXT_PUBLIC|EXPO_PUBLIC|REACT_APP)_[A-Z0-9_]*(SECRET|SERVICE|PRIVATE|TOKEN|PASSWORD|OPENAI|STRIPE_SECRET)" \
--include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" .This is a starting point, not a complete list; read through every public-prefixed variable in your .env files and ask whether it is in the safe column above.
Search your built output. Build locally and search the files that will actually be deployed:
npm run build
grep -rnoE "sk_live_[A-Za-z0-9]+|sk-[A-Za-z0-9_-]{20,}|sb_secret_[A-Za-z0-9_-]+|service_role" dist/ | head(For Next.js, search .next/static instead of dist.)
Search the live site. Open your deployed app, open developer tools, and search all loaded scripts for sk_, sk-, secret and service_role. Or run RowLocked's free scan, which fetches your live JavaScript bundles the way a visitor would and reports secret-shaped strings, masked.
Where secrets belong instead
Any operation that needs a secret has to run somewhere the visitor cannot see. For apps built with Lovable or Bolt on Supabase, that is usually an Edge Function:
# Store the secret on the server side only
supabase secrets set STRIPE_SECRET_KEY=sk_live_...// supabase/functions/create-checkout/index.ts
import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
Deno.serve(async (req) => {
// Verify the caller (see our OpenAI guide for the full auth pattern), then:
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: "price_123", quantity: 1 }],
success_url: "https://your-app.com/success",
cancel_url: "https://your-app.com/cancel",
});
return Response.json({ url: session.url });
});The browser calls the function and receives only the checkout URL. The full pattern with authentication, input validation and rate limiting is in How to hide your OpenAI API key; it applies to every secret API.
For Next.js, use a route handler or server action and an environment variable without NEXT_PUBLIC_. Next.js only inlines public-prefixed variables into client bundles; unprefixed ones stay on the server. Importing the server-only package in modules that read secrets makes the build fail if one is accidentally imported into a client component:
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);If a secret was already in a public variable
Removing it from the code is not enough. The old bundles were public, may be cached by CDNs and browsers, and may be in your git history.
- Rotate the secret with the provider: create a new key, deploy it to your server-side environment, then revoke the old one. Provider-specific steps for Supabase are in service_role key exposed.
- Check usage in the provider's dashboard for activity you do not recognize.
- Clean the repository: make sure
.env,.env.localand.env.productionare in.gitignore. If the key was committed, rotating it matters more than rewriting history, because forks and clones may already have it. - Add guard rails: enable GitHub secret scanning and push protection, and re-scan the live site after deploys.
A prompt to fix this with your AI tool
List every environment variable this app uses, with its prefix and where it is read. Any variable that holds a secret (service_role or secret keys, Stripe secret keys, AI provider keys, email/SMS provider keys, database URLs, webhook secrets) must not use a VITE_ or NEXT_PUBLIC_ prefix and must not be read in browser code. Move every operation that needs those secrets into a Supabase Edge Function (or server route), store the values with supabase secrets set, and call the function from the frontend. Keep only public values (Supabase URL, anon/publishable key, Stripe publishable key) in VITE_ variables.Frequently asked questions
Is my .env file itself served to visitors?
It should not be, but check. Visit your-site.com/.env. You should get a 404 or your normal app page, never the file. Some static hosting setups and misconfigured servers do serve it.
Does Lovable keep my secrets safe automatically?
Lovable's Supabase integration lets you store secrets for Edge Functions, which is the right place. But if a prompt led to a secret being used in frontend code, it is still public. Check the built site, not just the settings screen.
Can I encrypt a secret before putting it in a VITE_ variable?
No. The browser would need the decryption key too, so the visitor would have both. Only server-side code can keep a secret.
Why does Vite expose any variables at all?
Frontends need some configuration, such as API URLs and public keys, and baking them in at build time is fast and simple. The prefix exists so you opt in deliberately, variable by variable.