Free 15-minute launch audit — we tell you exactly what stands between you and going live.Claim a slot
Security

The 5 Supabase RLS mistakes we find in almost every AI-generated app

AI coding tools scaffold Supabase beautifully and secure it badly. Here are the five Row Level Security holes we find in nearly every prototype, the exact SQL that closes them, and how to prove a policy works.

GEGen2ProdProduction engineering

5 min read

A glass vault door standing open in a wall of dark stone, violet light spilling through the gap

Every AI coding tool — Lovable, Bolt, v0, Cursor — is fluent in Supabase. It will wire up auth, generate tables, and produce a working CRUD app in minutes. What it will not reliably do is make that app safe to point at the internet.

We have audited a lot of these codebases. The same five Row Level Security (RLS) mistakes appear again and again, and every one of them is exploitable with nothing more than the anon key that ships inside your JavaScript bundle.

1. RLS is never enabled at all

The big one, and depressingly common. The tool creates a table, the app works, and nobody runs the single statement that turns the lock on.

With RLS off, the anon key reads and writes every row in that table. Check all of them at once:

select
  schemaname,
  tablename,
  rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public'
order by rls_enabled, tablename;

Every false is a table the internet owns. Fix it one table at a time:

alter table public.profiles enable row level security;

Enabling RLS with no policies denies everything, which will break your app loudly. That is the correct order of operations: break it, then open exactly the doors you need. An app that works because nothing is locked is not working.

2. The using (true) policy

The second most common pattern is a policy that exists and protects nothing:

-- Looks like security. Is not security.
create policy "enable read access for all users"
  on public.orders for select
  using (true);

This is the default snippet in Supabase's own policy templates, and every AI tool has read it. On a public posts table it is correct. On orders, messages, or subscriptions it means any visitor can page through every record you hold.

Scope it to the row's owner, and name the role it applies to:

create policy "users read their own orders"
  on public.orders for select
  to authenticated
  using ((select auth.uid()) = user_id);

Two details in there earn their place. to authenticated stops the policy being evaluated for anonymous requests at all. And wrapping the call as (select auth.uid()) lets Postgres evaluate it once for the whole query instead of once per row — on a large table that difference is not subtle.

3. select is protected, update and delete are not

Policies are per-operation. A for select policy says nothing about writes, and a table with only a read policy will reject writes — until somebody "fixes" that by adding a permissive for all.

We regularly find tables where reads are correctly scoped and update is wide open: an attacker cannot see your data, but can happily destroy it. Audit what actually exists rather than what you remember writing:

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

You need a policy per operation you intend to allow, and with_check matters as much as using:

ClauseApplies toThe question it answers
usingselect, update, deleteWhich existing rows may I touch?
with_checkinsert, updateWhat am I allowed to write?

An update policy with a using clause and no with_check lets a user edit their own row and reassign its user_id to somebody else on the way out. The read policy was never the problem.

4. Trusting a column the client controls

This one is subtle, and it is our favourite to find, because the code looks careful:

create policy "admins do anything"
  on public.posts for all
  using ( (select role from public.profiles where id = (select auth.uid())) = 'admin' );

Reasonable — unless profiles.role is itself writable by its owner. If that table has a permissive update policy, any account promotes itself to admin with one request and then walks through this policy entirely legitimately.

Privilege data has to live somewhere the user cannot write. Put it in a table with no user-facing write policy at all, and read it through a security definer function:

create or replace function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $fn$
  select exists (
    select 1 from public.admins a where a.user_id = (select auth.uid())
  );
$fn$;
 
revoke all on function public.is_admin() from public;
grant execute on function public.is_admin() to authenticated;

set search_path = '' is not decoration. Without it, a security definer function can be redirected to an attacker-controlled table through the search path — a privilege-escalation bug in the thing you wrote to prevent privilege escalation. Fully qualify every name inside the body, as above.

5. The service-role key in the browser

Finally, the mistake that makes the other four irrelevant. The service_role key bypasses RLS completely, by design. It belongs on a server, in a secret store, and nowhere else.

Anything named NEXT_PUBLIC_*, VITE_*, or PUBLIC_* is compiled into your client bundle. Grep before every deploy:

grep -rInE 'service_role|SUPABASE_SERVICE' src/ app/ components/ .env* 2>/dev/null

A hit anywhere a browser can reach means rotating the key in Supabase → Settings → API, immediately, and treating everything it could touch as read. How to find the keys your AI tool leaked walks the full five-minute version of that audit, including the git history — a key removed in a later commit is still in the repository.

Proving a policy actually works

Reading a policy and believing it is how most of these bugs survive review. Test it the way the database will see it, by becoming the role:

-- Pretend to be a signed-in user, in a transaction you can throw away.
begin;
select set_config('request.jwt.claims',
  '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
 
select count(*) from public.orders;          -- expect: only their rows
update public.orders set total_cents = 1;    -- expect: 0 rows affected
rollback;

Then run the same block as anon with no claims set. The test that matters is not "my app still works" — it is "the request I am not supposed to be able to make comes back empty". Write the malicious query first and watch it fail.

Two more checks worth building into that habit:

  • Storage is a separate policy surface. Buckets have their own RLS on storage.objects, and a public bucket is genuinely public — including the files someone uploaded to a private-looking part of your app.
  • Grants still exist underneath. RLS filters rows; it does not remove a table-level grant. If anon has no business touching a table at all, revoke its privileges as well and let RLS be the second line rather than the only one.

A 10-minute self-audit

Run these five against your project right now:

  1. Every public table has RLS on — the pg_tables query returns no false.
  2. No bare using (true) on anything user-specific.
  3. Explicit policies per operation, and every insert or update policy has a with_check.
  4. Privilege flags live in a table users cannot write, read through a security definer function with an empty search_path.
  5. No service-role key in any client-reachable file or NEXT_PUBLIC_ variable.

If any of those fail, you have a live data-exposure bug rather than a future one.

Where this sits in a launch

RLS is the first section of a longer list, and it is first because it is the only one where the failure mode is "a stranger has your customers' data" rather than "the app is slow". The rest of that list — auth flows, storage policies, secret handling, rate limits, error reporting — is written up in the launch checklist we run before any AI-built app goes live, and what to fix before you launch puts it in the order we work through it.

Want a second pair of eyes on yours? A free 15-minute launch audit is where that starts: we look at the repo and tell you honestly what stands between you and going live. If you would rather have the whole surface fixed rather than described, that is a production sprint.

Share

Your AI-built product deserves a real launch.

Start with a free 15-minute call. We'll tell you honestly what it takes to get you live — no pressure, no jargon.

Senior engineers only · NDA on request · Your code stays yours