Row Level Security: the Supabase setting your AI app cannot skip

Lovable, Bolt and Cursor apps ship the Supabase key in the browser. Only Row Level Security protects your data. Check yours in five minutes.

October 15, 2026 · 4 min read · supabase, lovable, bolt, row level security

If your app talks to Supabase from the browser, the browser has your database key. That is how Supabase is meant to work: the anon key is public by design, and the thing standing between a stranger and every row in your tables is Row Level Security (RLS). When RLS is off, or on with a policy that says yes to everyone, anyone who opens developer tools can read and write your whole database with a few lines of JavaScript.

This is not a theoretical bug. In 2025, a researcher scanned apps built with Lovable and found that 170 of the 1,645 they checked exposed user data this way. The issue got a CVE number, CVE-2025-48757, which is unusual for a configuration mistake and tells you how widespread it was. AI tools have improved since, but they still generate the table first and the policy later, or never, because the app works either way.

The three shapes of the mistake

AI-generated Supabase projects fail in one of three ways. SixthWall has a rule for each, so you can scan rather than read.

1. The table was created and RLS was never turned on. A migration contains create table profiles (...) and nothing afterwards says alter table profiles enable row level security. Every row is readable and writable with the anon key. This is the most common shape, because the SQL the AI writes to make the feature work does not need the policy. Rule: AI-SUPA-004.

2. RLS was turned off on purpose. Somewhere there is alter table ... disable row level security. Usually the AI did this to make a failing query pass during development and nobody turned it back on. Rule: AI-SUPA-002.

3. RLS is on, but the policy is a no-op. create policy "allow all" on orders for all using (true) is RLS in name only. So is a policy that grants insert, update or delete to anon. Dashboards show a green "RLS enabled" tick for these tables, which makes this shape the hardest to spot by eye. Rule: AI-SUPA-003.

A fourth, separate mistake is putting the service_role key in frontend code. That key bypasses RLS entirely, so no policy helps. Search your project for service_role and for any NEXT_PUBLIC_ or VITE_ variable that holds it. Rule: AI-SUPA-001.

How to check in five minutes

Run this in your project folder. It needs nothing but Node, and no code leaves your machine.

npx @sixthwall/cli scan --full

Each Supabase finding names the table, the migration file and the line, and comes with a Fix Pack: the smallest SQL change, a safer pattern, a prompt you can paste into Lovable, Cursor or Claude Code, and how to test that it worked.

If you prefer to check by hand, open the SQL editor in Supabase and run:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public';

Any table with rowsecurity = false is open. For the ones that are true, list the policies:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public';

A qual of true means everyone passes. Roles containing anon on anything other than SELECT means logged-out visitors can write.

What a real policy looks like

The pattern that fits almost every vibe-coded app is "a user sees and edits their own rows". For a table with a user_id column:

alter table orders enable row level security;

create policy "own rows" on orders
  for all
  to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Two things to notice. to authenticated means logged-out visitors get nothing. using controls which rows can be read, updated or deleted, and with check controls what can be inserted or what a row may become after an update. Leave out with check and a user can insert rows that belong to someone else.

For data that is genuinely public, such as a product catalogue, write the policy to say exactly that and nothing more:

create policy "public read" on products
  for select
  to anon, authenticated
  using (true);

for select with using (true) is fine. for all with using (true) is the bug.

Test it the way an attacker would

Do not trust the dashboard tick. Open a private browser window, do not log in, and run this in the console on your own site with your anon key:

const { data, error } = await supabase.from('orders').select('*');
console.log(data?.length, error);

You want zero rows or a permission error. If you see other people's orders, you have found the problem before someone else did.

Then log in as one test user and try to update a row that belongs to another test user. It should fail. This is the test the Fix Pack tells you to run, and it is the only one that counts.

Why AI tools get this wrong

The model is rewarded for a working feature. A table without RLS works. A policy that says true works. The failure only shows up when a person who is not you tries to read what is not theirs, and nothing in the development loop does that. That is why SixthWall checks the migration files with fixed rules rather than asking a model whether the code looks safe: the rule fires on using (true) every time, in every project, whether or not the surrounding code looks reasonable.

If you build on Supabase, make the scan part of the loop. npx @sixthwall/cli init adds it to Claude Code, Cursor and your pre-commit hook, and the GitHub App checks every pull request so a policy cannot quietly be deleted later.