All posts
VibeZero Team7 min read

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.

ShareXLinkedIn