Skip to content
coderband

Supabase Row Level Security: 10 Mistakes AI-Built Apps Make

The 10 Supabase RLS mistakes in AI-built apps, from RLS left off to user_metadata roles and leaky views, with bad and fixed SQL and a test for each one.

coderband engineering16 min read

The most common Row Level Security mistakes in AI-built Supabase apps are tables with RLS switched off, policies that allow everyone (using (true)), and write policies that never check who owns the row. Each one turns the public key in your JavaScript bundle into a key to other people’s data. That’s how CVE-2025-48757 happened: 170 of 1,645 Lovable apps scanned had 303 endpoints anyone could query without logging in, and Escape later found more than 2,000 vulnerabilities across 5,600 vibe-coded apps, with missing or permissive RLS as the main pattern.

Key takeaways

  • The public key is only as safe as your policies. Supabase says the publishable key reaches only what RLS allows. With RLS off, that’s everything.
  • “RLS enabled” doesn’t mean “RLS correct.” Nine of the ten mistakes below happen with RLS switched on.
  • Writes fail differently from reads. A missing with check or an editable role column lets users create or change data they shouldn’t, even when reads look fine.
  • Views, functions and storage buckets have their own rules. Views and security definer functions can bypass RLS entirely, and a public bucket ignores read policies.
  • Test as someone other than the owner. The person running the demo is always the owner. Supabase’s Security Advisor and a small pgTAP suite catch most of this list.

If you’ve already worked through the RLS items in our production-readiness checklist, this post goes one level down: what each mistake looks like in SQL, why AI builders keep producing it, and how to prove you’ve fixed it.

Why AI builders get RLS wrong

Lovable, Bolt and similar tools generate a React frontend that talks to Supabase directly from the browser. There’s no server of your own in between, so every access rule has to live in Postgres as an RLS policy. (We compare how each builder handles this in Lovable vs Bolt vs Replit vs Cursor in production.)

RLS fails quietly. When RLS is on and no policy allows a read, Postgres returns no rows instead of an error. To an agent asked to “fix the empty dashboard,” the fastest path to a working screen is a broader policy or no policy at all. The screen works, the demo works, and the hole stays invisible, because nobody tests as a second user.

The examples below use an invoices table with a user_id column. Swap in your own tables.

1. RLS turned off, or never turned on

What it looks like. A table created from the SQL editor, a migration or an AI agent, with no enable row level security. Supabase’s docs say tables made in the Dashboard get RLS by default, but tables created any other way need it turned on explicitly.

Why AI builders produce it. Agents often create tables with raw SQL. And when RLS is on with no policies, nothing is readable with the publishable key, which looks like a bug, so RLS gets switched off. The other escape hatch is swapping in the service_role or secret key, which bypasses every policy.

-- Bad: created by a migration, never locked down
create table public.invoices (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users (id),
  amount_cents integer not null
);

-- Fixed: RLS on, plus an explicit policy for who can read what
alter table public.invoices enable row level security;

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

How to test. Run the Security Advisor (or supabase db advisors) and clear lint 0013_rls_disabled_in_public. Then list unprotected tables yourself:

select n.nspname as schema_name, c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r'
  and n.nspname = 'public'
  and not c.relrowsecurity;

Finally, query each table logged out with only the publishable key from your site’s bundle. Any rows that come back are public.

2. Policies that are true for everyone

What it looks like. A policy whose condition is true, or one that only checks that someone is signed in.

Why AI builders produce it. It makes the empty-result “bug” go away, and it reads as reasonable: “authenticated users can read invoices.” But anyone can sign up, and if you’ve turned on anonymous sign-ins, Supabase notes that anonymous users use the authenticated role too.

-- Bad: every signed-in account (including anonymous ones) reads every invoice
create policy "Authenticated users can read invoices"
on public.invoices for select
to authenticated
using (true);

-- Fixed: scope rows to their owner
drop policy "Authenticated users can read invoices" on public.invoices;

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

Note the to authenticated. Without a to clause, a policy applies to every role, including anon. Supabase recommends always naming the role.

How to test. Lint 0024_permissive_rls_policy flags always-true conditions, and 0012_auth_allow_anonymous_sign_ins flags policies reachable by anonymous users (advisor list). You can also search directly:

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

Some hits are intentional, like a public blog. Each one should be a decision, not a default.

3. Write policies that don’t pin ownership

What it looks like. Insert and update policies whose with check is true or missing the owner check. using decides which existing rows you can touch; with check decides what the new row is allowed to look like (Postgres docs).

Why AI builders produce it. Reads get tested in the UI. Writes get tested by clicking “Save” as the owner, which passes either way.

-- Bad: any user can insert a row owned by someone else,
-- or hand their own row to another user
create policy "Users can create invoices"
on public.invoices for insert
to authenticated
with check (true);

create policy "Users can edit their invoices"
on public.invoices for update
to authenticated
using ( (select auth.uid()) = user_id )
with check (true);

-- Fixed: the new row must belong to the caller, too
create policy "Users create their own invoices"
on public.invoices for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users edit their own invoices"
on public.invoices for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

(Drop the old policies first; otherwise they still apply, as mistake 6 explains.) One detail that helps: if an update policy has no with check at all, Postgres reuses the using expression for both. An explicit with check (true) overrides that safety net.

How to test. Signed in as user A, insert a row with user B’s user_id, then update one of your own rows to set user_id to B. Both should fail with error 42501.

4. Users can edit the columns that decide access

What it looks like. A profiles table with role, is_admin, plan or org_id, and a correct “users can update their own profile” policy. RLS is row-level, so the user can update every column in that row, including is_admin.

Why AI builders produce it. Putting everything about a user on one row is the obvious data model, and the update policy looks right in review. A related version: filtering by org_id in the client (a .eq('org_id', ...) call) and treating that as access control. A filter the browser sends is a suggestion, not a rule.

-- Bad: correct row ownership, but the row includes privileges
create policy "Users update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );
-- A user can now run: update profiles set is_admin = true where id = their id

-- Fixed: privileges live in a table users can read but never write
create table public.user_roles (
  user_id uuid primary key references auth.users (id) on delete cascade,
  role text not null check (role in ('member', 'admin'))
);

alter table public.user_roles enable row level security;

create policy "Users read their own role"
on public.user_roles for select
to authenticated
using ( (select auth.uid()) = user_id );
-- No insert, update or delete policies: only your server writes here.

Supabase recommends this dedicated roles table approach for most apps. The alternative is column privileges (revoke update on table public.profiles from authenticated, then grant update on named columns only), which Supabase calls an advanced feature because select * stops working for that role.

How to test. Signed in as a normal user, call update on your own profile and set every privilege-like column. All of them should fail or be ignored.

5. Roles read from user_metadata

What it looks like. A policy that checks auth.jwt() -> 'user_metadata' (or raw_user_meta_data) for a role, a plan or a team.

Why AI builders produce it. user_metadata is where signup forms put extra fields, so it’s the first place an agent finds a “role.” But the signed-in user can change that field themselves with a single updateUser call from the browser.

-- Bad: the user can set this claim to 'admin' themselves
create policy "Admins read all invoices"
on public.invoices for select
to authenticated
using ( (select auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' );

-- Fixed: app_metadata can only be set server-side
drop policy "Admins read all invoices" on public.invoices;

create policy "Admins read all invoices"
on public.invoices for select
to authenticated
using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );

The app_metadata claim lives in the JWT, so a role change shows up only after the token refreshes. If you need instant revocation, check the user_roles table from mistake 4 instead.

How to test. Lint 0015_rls_references_user_metadata is rated ERROR in the Security Advisor. Also grep your frontend and Edge Functions for user_metadata, because the same mistake shows up in application code.

6. Expecting policies to stack like filters

What it looks like. A broad policy from an early prompt, plus a newer, stricter one meant to “lock down” access. In Postgres, policies are permissive by default, and permissive policies are combined with OR. The new policy can only add access, never remove it.

Why AI builders produce it. Each prompt adds a policy and nothing removes the old ones. Agents rarely read the full list of policies on a table before writing a new one.

-- Bad: the second policy changes nothing, because the first already allows every row
create policy "Signed-in users read lessons"
on public.lessons for select
to authenticated
using (true);

create policy "Only subscribers read premium lessons"
on public.lessons for select
to authenticated
using (
  not is_premium
  or exists (
    select 1 from public.subscriptions s
    where s.user_id = (select auth.uid()) and s.status = 'active'
  )
);

-- Fixed: keep a permissive base policy and make the limit restrictive
drop policy "Only subscribers read premium lessons" on public.lessons;

create policy "Premium lessons need a subscription"
on public.lessons
as restrictive
for select
to authenticated
using (
  not is_premium
  or exists (
    select 1 from public.subscriptions s
    where s.user_id = (select auth.uid()) and s.status = 'active'
  )
);

Restrictive policies are combined with AND, and they need at least one permissive policy to pass first. With only restrictive policies, no rows are visible.

How to test. Lint 0006_multiple_permissive_policies flags overlaps. To review every table with more than one permissive policy per command:

select schemaname, tablename, cmd, count(*) as permissive_policies
from pg_policies
where permissive = 'PERMISSIVE'
group by schemaname, tablename, cmd
having count(*) > 1;

7. Views that bypass RLS

What it looks like. A view in public built for a dashboard or a summary. By default, a Postgres view checks permissions as the view’s owner, not the caller, and on Supabase that owner is usually postgres, so the view ignores RLS on the tables underneath.

Why AI builders produce it. “Make a view for the stats page” is a natural prompt, and Postgres’s default is the unsafe one.

-- Bad: anyone with the publishable key reads every user's totals
create view public.invoice_totals as
select user_id, count(*) as invoice_count, sum(amount_cents) as total_cents
from public.invoices
group by user_id;

-- Fixed (Postgres 15+): the view runs with the caller's permissions and policies
alter view public.invoice_totals set (security_invoker = true);

How to test. Lint 0010_security_definer_view (ERROR) catches these, and 0002_auth_users_exposed catches views that expose auth.users (advisor list). Then query each view as a logged-out visitor and as a second user.

8. security definer functions anyone can call

What it looks like. A Postgres function marked security definer, called with supabase.rpc(), that takes a user ID or org ID as a parameter. It runs as its owner, and functions created in the Dashboard or by migrations are owned by postgres, which bypasses RLS. By default, any role can run it, including anon.

Why AI builders produce it. security definer is the quickest way to make a “permission denied” error go away. The Register described a Lovable-built app whose RPC-based auth function had its logic inverted, blocking signed-in users and letting anonymous ones through, which exposed 18,697 user records.

-- Bad: callable by anon, trusts the caller's parameter, bypasses RLS
create function public.get_invoices(target_user uuid)
returns setof public.invoices
language sql
security definer
as $$
  select * from public.invoices where user_id = target_user;
$$;

-- Fixed: run as the caller, take identity from the JWT, limit who can execute
drop function public.get_invoices(uuid);

create function public.get_my_invoices()
returns setof public.invoices
language sql
security invoker
set search_path = ''
as $$
  select * from public.invoices where user_id = (select auth.uid());
$$;

revoke execute on function public.get_my_invoices() from public, anon;
grant execute on function public.get_my_invoices() to authenticated;

When a function genuinely needs security definer, Supabase says to set search_path and to never create it in an exposed schema. Put it in a private schema and check auth.uid() inside it.

How to test. Lints 0028_anon_security_definer_function_executable, 0029_authenticated_security_definer_function_executable and 0011_function_search_path_mutable. Then list them:

select n.nspname as schema_name, p.proname as function_name
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef
  and n.nspname = 'public';

9. Storage buckets that are public, or policies with no owner

What it looks like. User uploads (invoices, IDs, contracts) in a bucket marked public, or a private bucket whose policy lets any signed-in user read any file in it.

Why AI builders produce it. Public buckets make image URLs just work. But a public bucket bypasses access control for reading files: anyone with the URL gets the file, whatever your policies say. Policies on storage.objects still govern uploads, deletes and moves.

-- Bad: any signed-in user reads every file in the bucket
create policy "Authenticated users read documents"
on storage.objects for select
to authenticated
using ( bucket_id = 'documents' );

-- Fixed: private bucket, one folder per user
update storage.buckets set public = false where id = 'documents';

drop policy "Authenticated users read documents" on storage.objects;

create policy "Users read their own documents"
on storage.objects for select
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);

create policy "Users upload to their own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);

This follows Supabase’s own folder-per-user example. For sharing a private file, use a signed URL, which is valid for a limited time.

How to test. select id, public from storage.buckets; and justify every true. Lint 0025_public_bucket_allows_listing flags public buckets whose contents can be listed. Then open a private file’s URL in a logged-out browser, and try to read user A’s file while signed in as user B.

10. Membership policies that recurse, or crawl

What it looks like. A multi-tenant app with an org_members table, and a policy on that table that queries the same table. Postgres throws an infinite recursion error (42P17), and the next prompt “fixes” it with using (true). The same pattern on large tables, without indexes, gets slow enough that someone turns RLS off.

Why AI builders produce it. “Members can see other members of their org” translates naturally into a self-referencing query. Supabase’s documented fix is a security definer helper function that breaks the cycle.

-- Bad: the policy on org_members reads org_members, which triggers the policy again
create policy "Members see their org's members"
on public.org_members for select
to authenticated
using (
  org_id in (
    select org_id from public.org_members where user_id = auth.uid()
  )
);

-- Fixed: a helper in a schema the API doesn't expose
create schema if not exists private;
grant usage on schema private to authenticated;

create function private.user_org_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
  select org_id from public.org_members where user_id = (select auth.uid());
$$;

revoke execute on function private.user_org_ids() from public, anon;
grant execute on function private.user_org_ids() to authenticated;

drop policy "Members see their org's members" on public.org_members;

create policy "Members see their org's members"
on public.org_members for select
to authenticated
using ( org_id in (select private.user_org_ids()) );

create index org_members_user_id_idx on public.org_members (user_id);

The (select ...) wrappers matter for speed. On a 100K-row test table, Supabase measured an un-indexed auth.uid() = user_id policy at 171 ms and under 0.1 ms with an index, and a role-check function ran roughly four orders of magnitude faster once wrapped in select (Supabase RLS performance guide).

How to test. Lint 0003_auth_rls_initplan flags unwrapped auth.* calls. Run explain analyze on your busiest queries as the authenticated role and look for sequential scans on policy columns. Then sign in as a member of org A and try to read org B’s rows by ID.

The 10 mistakes at a glance

# Mistake Risk Fix How to detect
1 RLS off on a table Anyone with the public key reads or writes it enable row level security plus policies Lint 0013; logged-out query
2 using (true) or “any signed-in user” Every account reads every row Scope to auth.uid() or org Lint 0024; pg_policies search
3 Write policy without an owner check Users create or reassign rows they don’t own with check on insert and update Insert as A with B’s ID
4 Privilege columns on user-editable rows Users promote themselves Separate roles table or column grants Update your own is_admin
5 Roles in user_metadata Users grant themselves roles app_metadata or a roles table Lint 0015; grep the code
6 Policies assumed to stack as AND A “lock-down” policy does nothing Drop the broad policy or use as restrictive Lint 0006
7 Views running as postgres View exposes every row security_invoker = true Lints 0010, 0002
8 Callable security definer functions RPC reads or writes past RLS security invoker, revoke execute, private schema Lints 0028, 0029, 0011
9 Public buckets or owner-less storage policies Private files readable by URL Private bucket, per-user folders, signed URLs Lint 0025; logged-out URL test
10 Recursive or slow membership policies Policy “fixed” with true, or RLS turned off Definer helper in a private schema, indexes Lint 0003; explain analyze

Testing as someone other than the owner

Every mistake above passes a click-through demo. What catches them is testing as a second user and as nobody. Supabase’s docs recommend pgTAP tests for every table, covering select, insert, update and delete for both anon and authenticated. Create a file with supabase test new invoices_rls and run the suite with supabase test db (testing guide).

begin;
create extension if not exists pgtap with schema extensions;
select plan(3);

-- Test data, created as postgres and rolled back at the end
insert into auth.users (id, email) values
  ('11111111-1111-1111-1111-111111111111', '[email protected]'),
  ('22222222-2222-2222-2222-222222222222', '[email protected]');

insert into public.invoices (user_id, amount_cents)
values ('11111111-1111-1111-1111-111111111111', 5000);

-- Act as user B
set local role authenticated;
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';

select is_empty(
  $$ select * from public.invoices $$,
  'User B cannot see user A''s invoices'
);

select throws_ok(
  $$ insert into public.invoices (user_id, amount_cents)
     values ('11111111-1111-1111-1111-111111111111', 1) $$,
  '42501',
  null,
  'User B cannot create an invoice owned by user A'
);

-- Act as a logged-out visitor
set local role anon;

select is_empty(
  $$ select * from public.invoices $$,
  'Logged-out visitors see nothing'
);

select * from finish();
rollback;

If you’ve revoked anon’s table grants, the last test should use throws_ok with 42501 instead, since a missing grant raises an error before any policy runs. Supabase also warns against using lives_ok to prove a write worked, because it passes when zero rows match. Use returning and check the value.

Two places this list doesn’t cover, but worth a look while you’re in there:

  • Realtime. Postgres Changes respects your table policies. Broadcast and Presence use policies on realtime.messages, and to enforce private channels you have to turn off “Allow public access” in Realtime settings.
  • Default grants. Tables in public get grants that make new objects reachable through the Data API, and Supabase says it’s changing that default so exposure becomes opt-in. Until your project has the new default, RLS is the only thing between a new table and the internet.

When to bring someone in

If you ran the Security Advisor and the list was long, or you fixed it and aren’t sure the fixes hold for a second user, that’s the gap our AI-App Rescue Audit is built for. It’s $1,490, fixed, and takes 72 hours. A senior engineer reads your schema, every policy, function, view and bucket, along with the code that calls them, and you get a severity-ranked list with the exact fix for each finding. If we find fewer than five material issues, you get a full refund.

If you want us to make the fixes and write the pgTAP suite, book the Stabilization Sprint (from $7,500 for two weeks) within 30 days, and the audit fee is credited in full. For how RLS fits into the wider decision of fixing versus rebuilding, read what it costs to fix a vibe-coded app. Or book a call and tell us which of the ten you found.