Your API keys are in the build. Go look.

Bolt.new is fast because it makes decisions for you, and most of them are fine. One is not: it scaffolds Vite, Vite compiles anything named VITE_* straight into your bundle, and there is no warning when the thing you named that way was supposed to be a secret. Start there, then check your functions. Neither shows up as a broken app.

The prefix is the whole bug

Vite has one rule about environment variables: anything starting with VITE_ is available to your frontend code. It achieves that by replacing the variable with its value at build time. The string ends up sitting in a .js file you serve to the internet.

That is correct and necessary for the Supabase anon key, which is meant to be public. It is also exactly what happens to this:

# .env — every line here ships to the browser

✅ fine, meant to be public
   VITE_SUPABASE_URL=https://abcdefgh.supabase.co
   VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

❌ now public — rotate it
   VITE_OPENAI_API_KEY=sk-proj-...

❌ bypasses every policy you have
   VITE_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1...

There is no error. The app works better with the key there, which is usually why it got added. The only signal is the bill, or someone telling you.

Where those keys should go instead

A Supabase Edge Function. Your browser calls your function, your function reads the key from a secret at runtime and calls OpenAI or Stripe. The key never enters a file the public can download.

supabase secrets set OPENAI_API_KEY=sk-proj-...

Inside the function it is Deno.env.get("OPENAI_API_KEY"). Note there is no VITE_ prefix anywhere in that flow — that is the point.

Your Edge Functions answer to strangers

A deployed function is a public URL. That is not a misconfiguration, it is what deploying means. Two things follow that people miss.

First, JWT verification gets turned off to make things work. Webhooks and callbacks arrive without a user token, so the fix that unblocks them is to disable verification for that function. Reasonable — but now the function answers anyone, and it is probably holding a service key. That is an unauthenticated read-write path into your database that no database policy can see, let alone stop. If you disable verification, replace it with something: a shared secret header, or a signature check from whoever is calling.

Second, CORS gets set to *to clear a browser error. That lets any website call your function from a visitor's browser. It is a much smaller problem than the first one, and worth narrowing to your own domain once things work.

Nothing is rate limited, and that is a bill

Say you built the right thing: the OpenAI key lives in an Edge Function, the browser calls your function. Your function is still a public URL that spends your money on every request, with no ceiling.

One person in a loop, one scraper, one person who noticed a free AI endpoint, and you have a four-figure invoice. The same goes for signup: an unlimited signup endpoint gets you thousands of junk accounts, and if signup triggers an email, your sending domain's reputation goes with it.

Nothing you get by default handles this. Require an authenticated user for anything that costs money, count requests per user in a table and reject over the limit, and turn on whatever protection your email provider offers.

Then the policies, which Bolt does write

Credit where it is due: Bolt is usually decent about enabling Row Level Security. The catch is that enabled and correct are different, and almost every checklist only asks the first question. A policy of USING (true) is switched on, attached, passes the audit, and returns every row to everybody.

The other one to know about: an UPDATE policy needs both a USING clause and a WITH CHECK clause. USING says which rows you may touch; WITH CHECK says what they may look like afterwards. With only the first, a user edits a row they legitimately own and reassigns it to someone else — or sets their own role to admin. Postgres allows it, because the row passed the test before it changed.

Both patterns, and why a broken read policy returns an empty list instead of an error, are in the Row Level Security guide.

What to watch once it is shipped

Where Defencecore fits

The bundle and function fixes are yours — no log tells you a key should not have been public before it goes out. Defencecore covers what happens next: it reads your Supabase logs continuously, opens an incident when one of the signals above fires, and shows the evidence behind it. Read-only, so it can explain a problem and never cause one.

Frequently asked questions

Why did my secret key end up in the browser?
Because of the prefix. Bolt scaffolds Vite projects, and Vite compiles every environment variable starting with VITE_ directly into the JavaScript bundle at build time. That is intended for the anon key. A secret stored as VITE_OPENAI_API_KEY gets exactly the same treatment — it is not hidden, it is compiled in. Nothing warns you.
So which keys are safe to have in a Bolt app?
The Supabase URL and the anon key, and that is essentially it. Everything else — OpenAI, Stripe secret keys, Resend, and above all service_role — belongs in a Supabase Edge Function, where the value is read from a secret at runtime instead of baked into a file you serve.
Do I need auth on my Edge Functions?
Unless a function is deliberately public, yes. A deployed function is a public HTTPS URL that answers anyone who finds it, and functions usually hold a service key so they can do their job. Turning JWT verification off to make a webhook work leaves an unauthenticated path straight into your database.
Is rate limiting really a security problem?
It is the one that costs money fastest. A public endpoint that calls a paid API on your account will get hammered — by a scraper, a bot, or one user in a loop — and there is no natural ceiling. Signup endpoints have the same problem: no limit means thousands of junk accounts overnight.