How to secure a Bolt.new app
Bolt.new puts your Supabase service role key behind a VITE_ prefix, which publishes it to the browser. Here are the five fixes, worst first, with code.
- security
- vibe coding
- bolt
Bolt.new gets you from a prompt to a deployed app faster than almost anything else, and the security gaps it leaves behind are just as repeatable. The good news is the same as with any generator: a short list covers most of what an attacker would find, and none of the five fixes below is a rewrite.
Start with the first one. It is the one that turns a working app into someone else’s OpenAI bill.
1. Take the VITE_ prefix off anything secret
Bolt scaffolds Vite projects, and Vite has one rule about environment variables
that decides whether your keys are private or public. From the
Vite documentation: “Variables prefixed
with VITE_ will be exposed in client-side source code after Vite bundling.”
The same page carries the warning in plain language:
VITE_*variables should not contain sensitive information such as API keys. The values of these variables are bundled into your source code at build time.
Now read Bolt’s own introduction to databases, which tells new builders that “environment variables keep these values private” and then lists the variables to store your Supabase credentials in:
VITE_SUPABASE_URL
VITE_SUPABASE_ANON_KEY
VITE_SUPABASE_SERVICE_ROLE_KEY
The first two are fine. The third is a service role key behind the one prefix
that publishes it to every visitor. Bolt’s page is right that environment
variables keep credentials out of source control, which is a real benefit, and
it never mentions that VITE_ is the part that decides who can read the value.
A reader following it exactly ends up with the most powerful credential in their
project compiled into a public JavaScript bundle.
That matters because of what the key is.
Supabase’s API key documentation
says the service role key “uses the BYPASSRLS attribute, skipping any and all
Row Level Security policies you attach,” and instructs you to never “add it to
web pages, public documents, source code.” It is the master key to your
database, and it makes step 2 below irrelevant if it leaks.
Find out what you actually shipped:
npm run build
grep -rEo "eyJ[A-Za-z0-9_-]{20,}|sb_secret_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,}" dist/
eyJ starts a base64url-encoded JWT header, which is what legacy Supabase keys
are; sb_secret_ prefixes the newer Supabase secret keys, and sk- prefixes
OpenAI keys. Any match is public. Rotate it before you change any code, because
a key in a deployed bundle should be treated as already compromised.
Then rename the variable so the prefix stops publishing it. Anything the browser
must not read simply loses the VITE_:
# Not this
VITE_SUPABASE_SERVICE_ROLE_KEY=eyJ...
# This, and only ever read server-side
SUPABASE_SERVICE_ROLE_KEY=eyJ...
The rename alone will break any code that referenced
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY, which is the point: every one
of those call sites was running in the browser and has to move to a server.
2. Move the call into a Netlify function
Bolt publishes to Netlify, so the server you need already exists and costs nothing to use. A function holds the secret, checks the caller, and never ships to the browser:
// netlify/functions/summarize.ts
import type { Handler } from "@netlify/functions";
const ALLOWED_ORIGINS = new Set([
"https://your-app.netlify.app",
"https://yourdomain.com",
]);
export const handler: Handler = async (event) => {
const origin = event.headers.origin ?? "";
const cors = ALLOWED_ORIGINS.has(origin)
? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
: {};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 204,
headers: { ...cors, "Access-Control-Allow-Headers": "authorization, content-type" },
};
}
if (!event.headers.authorization) {
return { statusCode: 401, headers: cors, body: "Unauthorized" };
}
const upstream = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
// Set with: netlify env:set OPENAI_API_KEY sk-... (no VITE_ prefix)
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-5.2",
input: JSON.parse(event.body ?? "{}").text,
}),
});
return {
statusCode: upstream.status,
headers: { ...cors, "Content-Type": "application/json" },
body: await upstream.text(),
};
};
Note the origin allowlist rather than Access-Control-Allow-Origin: *. Bolt
tends to reach for the wildcard because it makes the app work during
development, and the wildcard survives to production, where it lets any site on
the internet call your endpoint with a visitor’s credentials attached. An
allowlist plus Vary: Origin costs three lines and closes it.
3. Check ownership on the server, not in the component
Ask Bolt for an endpoint and you get an endpoint. Unless authorization was part of the same prompt, the route is reachable by anyone who finds the path, and the only thing enforcing your rules is a frontend that an attacker is not obliged to run. A logged-in user changing an id in a request gets someone else’s record.
Verify the caller’s token inside the function and scope the query to them:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const jwt = event.headers.authorization?.replace("Bearer ", "") ?? "";
const { data: { user }, error } = await supabase.auth.getUser(jwt);
if (error || !user) {
return { statusCode: 401, headers: cors, body: "Unauthorized" };
}
const { data } = await supabase
.from("documents")
.select("*")
.eq("id", documentId)
.eq("user_id", user.id) // the check the generated route skipped
.single();
The user_id filter is the whole fix. Authentication proves who is asking;
only that predicate proves they are allowed to have it.
4. Turn on Row Level Security
The anon key is supposed to be in your frontend. Supabase’s docs call it “safe to expose online,” and that is true precisely because Row Level Security is what stands between it and your data. Bolt generates Supabase migrations that create tables without adding policies, so on a fresh project there is nothing standing there at all: the anon key that ships in your bundle can read the table directly, and the login screen is decoration.
This is the failure behind CVE-2025-48757, disclosed by Matt Palmer in May 2025, in which more than 170 deployed projects leaked emails, API keys, and payment data through missing row-level security. It was reported against Lovable, and the mechanism is the backend, not the builder, so it applies to any Bolt project on Supabase.
Audit every table:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
Any row with rowsecurity = false is readable by anyone holding your anon key.
Enable it and write the policy in the same change, because enabling RLS alone
denies everything and breaks the app:
alter table public.documents enable row level security;
create policy "owner reads own documents"
on public.documents
for select
using (auth.uid() = user_id);
create policy "owner writes own documents"
on public.documents
for insert
with check (auth.uid() = user_id);
Write the read and write policies together. A for select policy on its own
leaves inserts and updates denied, the app appears broken, and the fastest way
to make it work again is a permissive for all using (true), which is worse
than where you started.
5. Update what shipped vulnerable
Generated manifests reach for the versions the model remembers rather than the current patched release, so a brand-new Bolt project can start life with known CVEs already installed. Nothing bumps the lockfile afterwards, and the app keeps working perfectly while the list grows underneath it.
npm audit --omit=dev
npm update
Re-run your tests afterwards, then confirm the audit is genuinely clean rather than merely quieter.
What this does not cover
These close the gaps that get Bolt apps breached, and they are not a security review. Nothing above inspects your storage bucket policies, your webhook signature verification, the security headers your deploy is missing, or the secret that leaked in an early commit and is still in your git history even though the working tree looks clean. Those belong to the common vulnerability classes that need something reading the whole repository rather than a checklist.
That is the honest limit of a post like this. A checklist tells you what to look for and cannot tell you what is in your code. VibeZero scans a Bolt repo across five layers, with Gitleaks and TruffleHog over the git history for the keys that step 1 only finds in the current build, and OSV-Scanner and Grype over the lockfile for step 5 (see Bolt.new security for what that covers). Either way, a fix is only real once something other than the tool that wrote the code confirms it, which is a problem worth understanding on its own.
The prefix is the lesson
Four of these five fixes are the ordinary discipline any app needs. The first one is different, and it is worth sitting with: the difference between a private credential and a public one was five characters in a variable name, documented in one tool and omitted in another, on a page written for people who are new to databases. Speed is not what makes generated apps risky. Defaults that look identical whether or not you got them right are.