Skip to content

Security basics

Vite env variables exposed: VITE_ and NEXT_PUBLIC_ mean publish this

Vite env variables exposed in your app did what the prefix asked. VITE_ and NEXT_PUBLIC_ mean publish this, and the AI that added one never knew the cost.

Vlad Tkachenko10 min read
A .env file with three values, two of them labelled VITE_, and the same two values drawn again inside a browser window beside it.

In short

  • Vite env variables exposed in your app are there because the prefix asked for it. A variable named VITE_ or NEXT_PUBLIC_ is copied into the JavaScript every visitor downloads, and keeping it in a .env file does nothing to stop that.
  • The prefix is the right label for an address or a publishable key and the wrong one for anything that spends money or ignores your database rules. The build cannot tell those apart, and neither can the AI that wrote the line.
  • Of 30,998 live vibe-coded apps we scanned, 1,332 shipped something key-shaped. 1,142 of those were Google API keys, which usually need a restriction and no rotation. 52 shipped a key that spends money or reads everything.

Somebody opened your Lovable app, pressed F12, and found a value in the code that you are certain you put in a .env file. Or you asked the builder for a feature, it wrote a line beginning VITE_, and something you have read since says that prefix is how keys leak. The file has .env in its name and every tutorial says never to share it. Vite env variables exposed to every visitor sounds like a bug in Vite.

Here is the part that guide after guide gets wrong: nothing failed to hide that value. VITE_ and NEXT_PUBLIC_ are an instruction to your build tool, and the instruction is to put the value into the app every visitor downloads. The tool read the label and did what it said.

A build packs your app into a box, and every visitor gets a copy of the box. The prefix is a label on one value that says pack this too. The rest of this post is about which values that label is right for, why the AI puts it on the wrong ones, and how to see what is in your own box.

Are Vite env variables exposed to visitors?

The ones that begin VITE_ are, and that is what the prefix is for.

Vite, the build tool behind most Lovable and Bolt apps, reads your .env file every time it builds. A variable named VITE_SUPABASE_URL is copied into the bundle, the compressed JavaScript file every visitor downloads, where your code reads it as import.meta.env.VITE_SUPABASE_URL. A variable named DB_PASSWORD, with no prefix, comes back empty in the browser. Vite's own documentation says the prefixed values are bundled into your source code at build time and should not hold API keys.

Next.js, which v0 builds with, has the same rule under a different name: NEXT_PUBLIC_. Its documentation describes the value as inlined, a hard-coded string written into the browser bundle when you build. Expo uses EXPO_PUBLIC_ and warns in the same words. Older Create React App projects use REACT_APP_. Each prefix tells its build tool the same thing: this one goes in the box.

Why an .env file feels private and is not

Because the habit of keeping a key in a .env file comes from servers, where it works.

On a server, the file and the code that reads it sit on a machine you control. A visitor gets an answer from that machine and never sees the file. Keeping the key out of the code and in a file the server reads at start is good practice there, and it is where every tutorial that says "put it in .env" was written.

Your .env file has two readers, and the tutorials are about one of them. The first is anyone who can see your project: a collaborator, a public GitHub repository, the builder's own file view. A .gitignore entry, which is a list of files git leaves out, keeps .env away from that reader. The second reader is the build, which opens the file every time you publish and copies out whatever carries the prefix. .gitignore says nothing to it.

One file, two readers. The .gitignore wall stops one of them. The build carries every prefixed value to the other.

So "my .env is gitignored" is true, and it answers a different question. The file stayed out of your repository. The values with a prefix went into the app anyway, because that is the route the prefix opens, and in a Lovable, Bolt or v0 project most of the code you have been editing runs in the browser, where there is no server for the file to stay behind.

Why the AI reached for the prefix

Because it is how you make a value work in browser code, and the model has no idea what the value costs.

You asked for a map, or a chat feature that answers questions about your product. The code the builder wrote for it runs in the visitor's browser, and browser code reading process.env.OPENAI_API_KEY gets nothing at all. The way to make the value arrive is the prefix. The builder renames the variable VITE_OPENAI_API_KEY, the feature works in the preview, and no error is raised anywhere, because from the build tool's side nothing went wrong.

The prefix carries no judgement. It is the same instruction for a Google Maps key, which is meant to be public once it is restricted, and for a Stripe secret key, which can refund every customer you have. A person who knew the second one bills your card would stop. The model knows that the feature did not work until the line was there, and then it did.

That is why this turns up on apps whose owners did everything they were told. The value was moved out of the code, it was kept in .env, the file was gitignored, and the app ships it anyway, because the one step that publishes it looks like the step that makes it work.

Which values belong behind the prefix

An address and a publishable key do. Anything that spends money or ignores your database rules does not.

ValueBehind VITE_ or NEXT_PUBLIC_?Why
VITE_SUPABASE_URLBelongs hereAn address. It says which project your app talks to and nothing else.
VITE_SUPABASE_PUBLISHABLE_KEY, or VITE_SUPABASE_ANON_KEY on an older projectBelongs hereDesigned for the browser. Every request it makes is still filtered by Row Level Security, the rules on each table that decide row by row who may read what.
A Stripe pk_live_ keyBelongs hereBuilds payment forms. Cannot charge, refund or read customers.
A Google Maps keyBelongs here, once restrictedPublic by design. A referrer restriction in Google Cloud is what stops a stranger billing you with it.
An OpenAI or Anthropic keyNeverThere is no publishable variant. Whoever holds it spends your money.
A Supabase service_role or sb_secret_ keyNeverBypasses Row Level Security and reads every row in every table.
A Stripe sk_live_ keyNeverCharges, refunds, payouts and every customer record.
An AWS access keyNeverWhatever that account can do, from anywhere.

If your app has VITE_SUPABASE_URL and a publishable key beside it, that is the pair Supabase intended for a browser, and our scan marks it as belonging there. The address and the publishable key are the reason the prefix exists.

The test for anything else is whether you would mind the value printed on your homepage. The prefix puts it one click further away than that, in a file instead of on the page, and anyone who wants the file has it. Telling the two families apart, by prefix and on an older Supabase key by the role inside it, is its own article.

What 30,998 apps shipped behind the prefix

Mostly Google API keys. 52 apps shipped a key that spends money or reads everything.

In August 2026 we ran the same nine checks on 30,998 live vibe-coded apps. 1,332 of them, 4%, shipped something key-shaped in the code every visitor downloads. 1,142 of those were Google API keys, which usually need a restriction set in Google Cloud and no rotation. 204 shipped a random-looking value sitting beside a name like secret or password, which may or may not be a real credential.

The expensive ones were rare. 33 apps shipped an OpenAI key, 9 an AWS access key, 5 an Anthropic key, 3 a Stripe secret key and 3 a Supabase service_role key: 52 apps in all, since one of them carried two. So the value behind the prefix is usually a Google key, and the fix for that is a setting. The rare case is where the damage is, and what a leaked OpenAI key costs is the post for that one.

How to check what your own app is shipping

Open the live app, press F12, and search every loaded file for the value.

  1. Open your published app in a browser, at its real address. The builder's preview is a different build and can be a version behind.
  2. Press F12 to open the developer tools, then choose the Sources tab.
  3. Press Ctrl+Shift+F, or Cmd+Option+F on a Mac. That opens a search across every file the page loaded.
  4. Paste the first ten or so characters of the value you are worried about, and paste it nowhere else.

A hit means the value is in the box every visitor gets. Search for the value rather than the name: the build usually replaces import.meta.env.VITE_OPENAI_API_KEY with the value itself, so a search for VITE_ can come back empty while every value behind it is there.

The build replaces the name with the value. Searching the live app for VITE_ finds nothing; searching for the value finds it.

If you would rather not go through your app value by value, our free scan reads your live site from outside, all nine checks, in about 20 seconds with no account. It names each key it finds by kind, says which ones belong in a browser, and prints "Couldn't check" for anything it could not answer rather than a tick: scan your app.

Where a real secret goes instead

On a machine your visitors never download from. In a Supabase project that is an Edge Function, a small piece of server code Supabase runs for you; in a Next.js app it is a server route; in a Replit app it is the server half.

The shape is the same everywhere. Your browser code asks your function to do the job. The function holds the key, makes the call to OpenAI or Stripe, and sends back the answer. The key stays on the machine and the visitor gets a result. The OpenAI post draws it as one object moving one box to the right, which is all the change is.

Two things tell you the builder did what you asked. The variable has lost its prefix, so it is OPENAI_API_KEY, and it lives in the function's own secrets, set in the Supabase dashboard under Edge Functions, with nothing in the app's .env. And the file that reads it sits under supabase/functions/ or app/api/, somewhere the build never packs, instead of under src/.

One order matters, and it is easy to get backwards. If a secret key has already shipped behind a prefix, moving it does nothing for the copies already downloaded. Rotate it at the provider first, then move the work. Whether to rotate first or close the leak first depends on whether the copy is already public, and once it has been in a published bundle, it is.

For a Replit project the same split has its own name, Secrets, and what the Secrets tool covers and what it does not is its own post.

What to do right now

What to do

  • Search your project for VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_ and REACT_APP_. Every match is a value your build publishes on purpose. Decide for each one whether it may be public.
  • Keep the address and the publishable key. VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY, or the anon key on an older project, are the pair the prefix exists for.
  • A secret key behind a prefix is rotated at the provider first, then moved. Deleting the line does not recall a copy that was already downloaded.
  • Move the work that needed the key into an Edge Function or a server route, with the key in that function's secrets and no prefix on its name.
  • Restrict a Google key by referrer in Google Cloud. That one needs a setting and keeps its value.
  • After the next publish, search the live app for each value you moved.

Every publish packs a new box. The next feature you ask for is another chance for a value to get the prefix, and nothing between the builder and the internet reads the bundle on the way out. A scan from last month read last month's bundle.

Reeve Monitor reads the bundle for you. It re-runs all nine checks every hour on up to three apps, tells you when a result changes, watches uptime every 60 seconds, and sends a monthly report. A key that reaches the bundle with a Tuesday publish is in that hour's re-scan, whether or not you remembered to look. It is $12 a month at list, with seven days free before it charges you; the pricing page is sometimes below the figure here and never above it.

If you would rather work through this as a list, the 10-minute security checklist covers this and the other things worth switching off in a newly launched app.

FAQ

Are .env files secret?

From your repository, yes, if the file is listed in .gitignore. From your visitors, no. The build reads .env every time you publish and copies every value with a VITE_ or NEXT_PUBLIC_ prefix into the JavaScript your app ships. The file itself never leaves your machine; the values you labelled for the browser do.

Is VITE_SUPABASE_ANON_KEY safe to expose?

It is meant to be there. The anon key, called the publishable key on a newer project, is designed to sit in a browser. It only says which project a request belongs to, and every request it makes is filtered by your Row Level Security rules. That holds for exactly as long as those rules are on and correct, which is a separate thing to check.

Is NEXT_PUBLIC_ any different from VITE_?

Same rule, different build tool. Next.js writes the value of any NEXT_PUBLIC_ variable into the browser bundle as a hard-coded string when you build, and a variable without the prefix comes back empty in browser code. Expo does the same with EXPO_PUBLIC_, and older Create React App projects with REACT_APP_. Whichever tool built your app, the prefix means publish.

How do I check what is in my bundle?

Open the live app, press F12, choose Sources, and press Ctrl+Shift+F (Cmd+Option+F on a Mac) to search every file the page loaded. Paste the first few characters of the value. Search for the value rather than the variable name, because the build usually replaces the name with the value, so VITE_ can be absent while the key is there. Our free scan does the same read from outside in about 20 seconds.

Where should a secret key live in a Lovable or Bolt app?

In a Supabase Edge Function, with the key set in that function's secrets in the Supabase dashboard and no VITE_ prefix on its name. Your browser code calls the function, the function calls the provider with the key, and the key never reaches a visitor. If the key has already shipped, rotate it at the provider before you move it.

Does .gitignore protect my keys?

It keeps the .env file out of git, so nobody reading your repository sees it. It has no effect on the build, which reads the file directly and publishes every prefixed value. A gitignored .env with VITE_OPENAI_API_KEY in it still ships that key to every visitor.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Not sure where your own app stands?

Run a free scan and get a plain-language grade from A to F in about 20 seconds. No account, no card.

Scan your app free

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.