Skip to content
Security

The four holes we find in almost every Bolt.new app

Almost every Bolt.new app has the same four holes: a service key in the bundle, row-level security off, unescaped output, an unprotected login route.

13 min readKato Chojiro

Bolt is very good at what it does. You describe an application, and an hour later there is a working full-stack app with a database behind it, deployed, with a URL you can send to someone. That is a genuinely remarkable thing and we are not here to be sniffy about it.

It is also remarkably consistent about what it leaves out. Across the Bolt projects that reach us for an audit, four findings recur so reliably that we now check them first. We do not find all four in every project. We find at least two in most of them, and each one on its own is enough to lose the contents of your database.

Hole 1: the service key is in your JavaScript bundle

Supabase issues two keys. The anon key is public by design - it ships in the browser, it identifies the anon Postgres role, and row-level security decides what that role may see. The service-role key is the opposite: it bypasses row-level security entirely, on every table, for every row. It is a master key with no lock it does not open.

It ends up in client code because at some point something did not work - a policy blocked a write, or a signup needed to create a related record - and swapping the key made it work. It is the single fastest way to unblock yourself, and it removes the entire security model in one line.

Check it in two minutes

Do this against your deployed site, not your local dev server. The bundle you serve is the one that matters.

bash
# Pull every script the deployed page loads and search the JWTs in it.
# A service-role key decodes to {"role":"service_role"}; an anon key to {"role":"anon"}.
curl -s https://your-app.example.com \
  | grep -oE 'src="[^"]+.js"' | cut -d'"' -f2 | sort -u \
  | while read -r path; do
      curl -s "https://your-app.example.com${path#/}" \
        | grep -oE 'eyJ[A-Za-z0-9_-]{10,}.eyJ[A-Za-z0-9_-]{10,}' \
        | while read -r jwt; do
            echo "$jwt" | cut -d. -f2 | base64 -d 2>/dev/null | grep -o '"role":"[a-z_]*"'
          done
    done | sort -u

If any line reads "role":"service_role", stop and treat it as an incident. Also check your git history - a key removed from the working tree is still in every clone of the repository that was made before you removed it.

How to close it

  1. Move the operations that needed it to an edge function or an API route, where the key stays server-side. This is usually the same code in a different file.
  2. Issue a new service-role key and deploy the server-side code with it. Verify the app works.
  3. Only then revoke the old key. Rotating first breaks the app while you are mid-way through fixing it, which is how a two-hour job becomes an outage.
  4. Scrub the git history if the key was ever committed, and assume the old key is compromised regardless.

Hole 2: row-level security was never switched on

Postgres denies by default once row-level security is enabled on a table. That is the correct failure mode, and it means a table with RLS on and no policies is safe - inconvenient, but safe. The dangerous state is a table where RLS was never enabled at all, because then every row is readable and writable through the REST API by anyone holding the public anon key. Which is everyone.

Tables created by a migration, by the SQL editor, or by a generated create table do not get RLS automatically. The dashboard shows a warning on a screen nobody revisits after the first week.

sql
select
  c.relname                          as table_name,
  c.relrowsecurity                   as rls_enabled,
  (select count(*) from pg_policies p
     where p.schemaname = 'public'
       and p.tablename = c.relname)  as policies
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relrowsecurity asc, c.relname;

Every row where rls_enabled is false is public. Fix those first, then look at the policies on the rest - and read them, because the second most common finding is a policy that is technically present and semantically meaningless:

sql
-- Both of these are public reads with extra steps.
create policy "Enable read access for all users"
  on public.profiles for select using (true);

create policy "Authenticated users can read"
  on public.orders for select using (auth.role() = 'authenticated');

The second one looks like a control and is not. If signup is open, anyone can become an authenticated user in fifteen seconds and then read every order in the table. Any policy whose predicate does not reference the current user's identity is not scoping anything.

Hole 3: user input rendered as HTML

Somewhere in the app there is a field a user controls - a display name, a bio, a comment, a project description - that gets rendered with dangerouslySetInnerHTML or an equivalent, usually because at some point someone wanted bold text to work. React escapes output by default, which is why this is rarer than it used to be, and the exceptions are almost always deliberate and almost always unsafe.

The consequence is not a defaced page. It is that a script in a stored field runs in another user's browser with that user's session, which means it can do anything they can do, including read the data your row-level security policies were carefully written to protect.

  • Grep for dangerouslySetInnerHTML, innerHTML, v-html, and any markdown renderer configured with raw HTML enabled.
  • Where rich text is genuinely needed, sanitise with an allowlist - DOMPurify or equivalent - rather than trying to filter out the dangerous cases.
  • Add a content security policy so a case you missed is not automatically exploitable. script-src 'self' alone removes most of the value of an injection.

Hole 4: nothing rate limits the login route

Credential stuffing is fully automated and extremely cheap to run. An attacker takes a list of email and password pairs from an unrelated breach, points it at your login endpoint, and keeps whatever works, because a meaningful share of people reuse passwords. With no rate limit, the only constraint is your server's throughput.

The same gap usually applies to signup - which is how you end up with ten thousand junk accounts overnight - and to password reset, which can be used to enumerate which email addresses are registered.

  1. Rate limit login, signup, and password reset by IP and by account. Five attempts per account per fifteen minutes stops the entire attack class.
  2. Require email verification before an account can do anything meaningful.
  3. Run the limits in log-only mode for the first week and look at what would have been blocked. Setting a limit from a blog post rather than from your own traffic is how you lock out a legitimate user on day one.
  4. Alert on the rate of failed logins, not just on the successful ones. A spike is the signal.

Why the same four, every time

None of these are bugs in Bolt. They are the consequences of an objective function: generate an application that works. All four of these findings make the app work sooner. The service key makes the blocked write succeed. Permissive policies make the query return rows. Raw HTML makes the formatting render. No rate limit makes the login fast. A model optimising for a working demo will choose each of them, every time, and it will be right to, because the demo is what it was asked for.

The gap is not between good code and bad code. It is between “works” and “works when someone is trying to make it not work”, and nothing in the generation loop ever asks the second question.

A ten-minute version of this article

  1. Search your deployed bundle for a JWT that decodes to service_role.
  2. Run the pg_class query and look for any table where rls_enabled is false.
  3. Grep for dangerouslySetInnerHTML.
  4. Open an incognito window and try your login endpoint twenty times in a row with a wrong password. Note whether anything stops you.

Four checks, ten minutes, and you will know more about your app's security posture than most of the people who ship one. If any of them come back positive and you would rather not fix them alone, here is what making a Bolt.new app production ready involves. The Production Check is free and the report is yours either way.

KC

Written by Kato Chojiro

Takes AI-built MVPs to production: security, data, and the infrastructure underneath.

Connect on LinkedIn
Book a Free Call

Want a second pair of eyes on your app?

The Production Check is free, and the written report is yours to keep.

Book a Free Call