VibeSecurity

Fundamentals

Bolt.new Security Checklist Before You Launch

This checklist is for founders and indie builders who made an app in Bolt.new and are about to put it in front of real users, whether it is hosted on Bolt, on Netlify, or exported to your own setup. Bolt builds run in a browser-based environment, most projects use Vite, and many connect to Supabase, so the same handful of mistakes show up again and again: a secret that quietly ended up in the shipped JavaScript, environment variables scoped too widely, no security headers, a database table with no Row Level Security, and a serverless function anyone can hammer. After reading this you will be able to find each of those in your own project in under an hour and fix it with settings and small files, not a rewrite.

By the VibeSecurity team11 min read

Where do secrets end up in a Bolt project?

Bolt's documentation describes two homes for sensitive values. Environment variables are, in Bolt's words, small pieces of information that your application reads while it is running, kept in a separate configuration layer so the project stays secure, flexible and easier to maintain, and out of source control. The examples the docs give are VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. The second home is secrets: values used by your server functions, stored alongside your database, so that API keys and database passwords are never exposed to users.

The distinction that matters on launch day is not env var versus secret. It is browser versus server. Anything your frontend code reads will be in the files a visitor's browser downloads. Anything only a server function reads stays on the server. Bolt's own variable names hint at this: the URL and anon key carry the VITE_ prefix because the browser needs them, and the service role key does not, because the browser must never have it.

So the first question to ask of every key in your project is: which side reads this? If the answer is the browser, the key must be one that is safe to publish. If it is a real secret, it belongs in a server function and in Bolt secrets or the hosting provider's server-side variables.

VITE_ variables are bundled into the browser

Vite's documentation is explicit: variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling. And then the warning: VITE_ variables should not contain sensitive information such as API keys, because the values of these variables are bundled into your source code at build time. For production, Vite suggests a backend server or serverless or edge functions to properly secure secrets.

This is the most common Bolt mistake, and it is easy to make because it works. Ask the AI to call an email or AI provider, it adds VITE_OPENAI_API_KEY or similar so the code can read it, and the feature runs in the preview. What you cannot see in the preview is that the key is now a string inside a JavaScript file anyone can open. Renaming the variable does not help; only moving the call into a server function does.

Check it directly rather than trusting the variable names. Run the build, then search the output folder for the beginnings of the key formats you use, such as sk- for several AI providers or sb_secret_ for Supabase. If a search finds a match, that key is already public in the sense that matters: anyone who loads your site can read it. Revoke it at the provider, issue a new one, and move the call into a server function before you launch.

Terminal: search the built output for leaked keys
npm run build
grep -rIl "sk-" dist/ || echo "no sk- prefix found"
grep -rIl "sb_secret_" dist/ || echo "no Supabase secret key found"
grep -rIl "service_role" dist/ || echo "no service_role found"

Scope server secrets on Netlify

If you host on Netlify, environment variables have scopes on Pro and Enterprise plans, and the scope decides where a value is used. Netlify's docs describe four: Builds, for the build step; Functions, described as the place to securely provide sensitive values such as API keys and tokens for your functions to use while they run; Runtime, for security tokens your forms and redirects use in the browser; and Post processing, for data Netlify injects as it serves the site, such as an analytics script. Values can also differ per deploy context, so production, deploy previews and branch deploys can each carry their own.

The practical rule: a provider key should be scoped to Functions only. A Builds-scoped value is available while Vite bundles, which is exactly when a VITE_ prefixed one gets copied into the output. Keep the two separate: VITE_ variables hold public values, and unprefixed variables scoped to Functions hold secrets. On plans without scoping, the variable is available everywhere, which makes the VITE_ prefix rule even more important.

Where each kind of value should live
ValueWhere the browser can see itRecommended home
Supabase URL and publishable or anon keyYes, by designVITE_ variable
Supabase service role or secret keyMust neverBolt secret or Functions-scoped Netlify variable
AI, email or payment provider keyMust neverBolt secret or Functions-scoped Netlify variable
Analytics idYesVITE_ variable or Post processing scope

Add security headers with a _headers file

Netlify lets you adjust the HTTP headers it serves through a plain text file named _headers in your publish directory. The docs note that if you run a build command, the file must end up in the folder you deploy, which for a Vite project means placing it in public/ so it is copied into dist/. The syntax is a path on one line, then indented header lines beneath it. Header names are case insensitive and lines beginning with a hash are comments.

Two limits are worth knowing. Netlify states that custom headers apply only to files it serves from its own store, so responses from functions or proxied content do not get them. And the headers are global for all builds and cannot be scoped to a branch or deploy context. A minimal set that helps most Bolt apps is below. Test the Content-Security-Policy in a deploy preview first, because a policy that blocks your Supabase URL or a font will break the page.

public/_headers
/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  Content-Security-Policy: default-src 'self'; connect-src 'self' https://*.supabase.co; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'

Enable Supabase RLS on every table

If your Bolt app connected to Supabase, your tables are reachable through Supabase's auto-generated API using the public key that is in your bundle. Supabase's docs state that a table in an exposed schema without RLS is readable and writable by any role with a grant on it. In other words, the key in the browser is enough to read the whole table unless Row Level Security is switched on and a policy limits which rows a request can touch.

Supabase's guidance is to enable RLS on the table and then add policies that behave like a WHERE clause on every query. The example pattern from the docs allows authenticated users to select only rows where auth.uid() matches the row's user_id. Enabling RLS with no policy denies everything through the API, which is the safe starting point. The Supabase docs also say the service_role bypasses RLS and to keep it server-side, which is one more reason it never belongs in a VITE_ variable.

Run the first query below in the Supabase SQL editor. Anything showing false needs attention before launch, and the statements that follow show the shape of the fix for one table. Then sign up as two different users on the live app and check that neither can see or edit the other's records, because a policy that exists is not the same as a policy that is right. Our Supabase RLS guide walks through writing the policies for each operation.

SQL editor: enable RLS and add an owner policy
select tablename, rowsecurity from pg_tables where schemaname = 'public';

alter table public.notes enable row level security;

create policy "Users can view their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

Rate limit your serverless functions

A function that calls a paid AI or email API on behalf of visitors is a cost and abuse surface: one script in a loop can run up your bill or exhaust the provider's quota. Netlify offers rate limiting on all plans through code-based rules. Its docs describe the feature as a way to control the number of requests to your project or specific paths, useful for mitigating denial of service, protecting backend services, preventing scraping and enforcing fair API usage. On Free, Starter and Personal plans you get two code-based rules per project, five on Pro, and a hundred on Enterprise with High-Performance Edge, which also unlocks UI-based rules.

The rule goes in the function's config export or in netlify.toml for a redirect. A request over the limit gets a 429 or a rewrite to another path, and Netlify notes enforcement can take up to ten seconds to begin. Use your two free rules on the endpoints that cost money: the AI call and the email or SMS sender. Keep your own authentication checks in the function as well; a rate limit slows abuse, it does not authorize anyone.

netlify/functions/ask-ai.ts
import type { Config } from "@netlify/functions";

export default async (req: Request) => {
  const key = process.env.OPENAI_API_KEY;
  return Response.json({ ok: Boolean(key) });
};

export const config: Config = {
  path: "/api/ask-ai",
  rateLimit: {
    windowLimit: 20,
    windowSize: 60,
    aggregateBy: ["ip", "domain"],
  },
};

Check the GitHub export before you rely on it

Sooner or later you will export the project or connect it to a repository, and that is a second place a secret can leak. Bolt's docs describe the intent well: because environment variables live outside the files that get committed, a codebase can be shared or published without exposing private credentials. That is true only if the .env file really is excluded and no key was pasted into a source file along the way.

Before making a repository public or handing it to a contractor, run through the steps below. They take a few minutes and they catch the case where a key was hardcoded early on, removed later, and still sits in an old commit. If a real secret appears anywhere in the history, rotating it is the only reliable fix, since removing a file in a later commit does not remove it from the earlier ones, and anyone who clones the repository gets the whole history.

  1. 1Open .gitignore and confirm .env and .env.* are listed.
  2. 2Run git ls-files | grep -i env to confirm no env file is tracked.
  3. 3Search the whole history: git log -p --all -S 'sk-' and the same for sb_secret_ and service_role.
  4. 4Search source files for provider domains and long token-looking strings the AI may have inlined.
  5. 5Anything found: revoke and reissue at the provider, then update the secret in Bolt or Netlify.
  6. 6Keep the repository private until the search is clean, and re-run it after large AI edits.

The launch checklist and the outside view

Everything above is inside-out: you have the code and the settings in front of you. The last step is outside-in. Open the live URL in a private window and look at it the way a stranger would: view the page source and network requests, confirm the only Supabase key in flight is the publishable one, and hit a few Supabase REST paths without logging in to confirm they refuse. A read-only external scan such as VibeSecurity automates that outside view against the published URL and repeats it after each deploy, which is useful precisely because it has no access to your project and can only see what an attacker sees.

None of this needs a security background. It needs about an hour, the list below, and the willingness to revoke a key the moment you find it somewhere it should not be. Keep the list next to your deploy button: most of the items only need rechecking when you add a table, a function or a new provider, but the built-output search and the two-account test are worth repeating after every large AI edit, because that is when a working feature quietly becomes an open one.

  • Built output searched; only the Supabase URL and publishable key are present.
  • Provider keys live in Bolt secrets or Functions-scoped Netlify variables, never VITE_.
  • _headers file deployed and verified in the response headers.
  • Every public table has RLS on with owner-scoped policies; two-account test passes.
  • Rate limits set on the functions that cost money.
  • No env file or key in the repository or its history.
  • External scan of the live URL is clean.

Frequently asked questions

Are VITE_ environment variables secure in a Bolt.new app?

No. Vite's documentation states that variables prefixed with VITE_ are exposed in client-side source code after bundling and should not contain sensitive information such as API keys. They are fine for public values like your Supabase URL and publishable key. Any real secret must move into a server function and be stored as a Bolt secret or a server-scoped hosting variable.

Where should I put API keys in a Bolt.new project?

In Bolt secrets, which Bolt's docs describe as values your server functions use to access sensitive information without exposing them to users, or on Netlify in an environment variable scoped to Functions. Then call the provider from a server function and have the browser call that function. Never put a provider key in a VITE_ variable, because it will be bundled into the shipped JavaScript.

How do I add security headers to a Bolt.new app on Netlify?

Create a file named _headers in the publish directory. For a Vite project put it in public/ so the build copies it into dist/. Write a path such as /* on one line and the headers indented beneath it. Netlify applies these only to files it serves itself, not to function responses, and the file is global across all deploy contexts.

Does Bolt.new set up Supabase Row Level Security for me?

Do not assume so. Check the Supabase SQL editor with select tablename, rowsecurity from pg_tables where schemaname = 'public'. Supabase documents that a table in an exposed schema without RLS is readable and writable by any role with a grant on it, which includes the public key in your bundle. Enable RLS on each table and add owner-scoped policies before launch.

Can I rate limit a Netlify function on the free plan?

Yes. Netlify's docs say basic rate limiting is available on all plans through code-based rules, with two rules per project on Free, Starter and Personal, five on Pro, and a hundred on Enterprise with High-Performance Edge. Add a rateLimit block to the function's config export with windowLimit, windowSize and aggregateBy, and requests over the limit receive a 429.

Put it into practice

Sources

  1. 1.Vite docs: Env variables and modes
  2. 2.Bolt docs: Introduction to databases
  3. 3.Netlify docs: Environment variables overview
  4. 4.Netlify docs: Custom headers
  5. 5.Netlify docs: Rate limiting
  6. 6.Supabase docs: Row Level Security