Supabase RLS is not enough on its own. Here is what else you need
Enabling Row Level Security and adding a policy does not remove the privileges Supabase granted your tables when they were created. Supabase's documentation is explicit: adding policies does not take those grants back. A table protected only by policies can still hand an anonymous visitor an insert path.
Almost every guide to Supabase security stops at one line of SQL. It is the right line, and on its own it does not finish the job.
alter table public.reports enable row level security;
Run that, write a policy, done. That advice is incomplete, and Supabase's own documentation says so in the second paragraph of its Row Level Security guide: adding policies does not take the grants back.
If you built your app with Lovable, Bolt, v0 or Cursor, this matters more to you than to most people. You almost certainly never set the grants yourself, because the tool created the tables and nobody was asked.
What is the difference between a grant and a policy?
Postgres runs two checks before anyone touches a table, and they answer different questions. Most guides only ever mention one of them.
A grant decides whether a role is allowed to run an operation on the table at all. A policy decides which rows that operation applies to.
Think of the grant as whether you have a key to the building, and the policy as which rooms you can open once you are inside. Enabling RLS without fixing grants hands out the building key and trusts the room locks.
Here is the part that catches people. On existing Supabase projects, a new table in the public schema starts with select, insert, update and delete already granted to all three roles, including anon, which is every signed-out visitor on the internet.
| Role | Granted automatically | What it should usually keep |
|---|---|---|
anon |
select, insert, update, delete | Only what a signed-out visitor should read |
authenticated |
select, insert, update, delete | Only what your app actually exposes |
service_role |
select, insert, update, delete | Everything, server-side only |
Your AI builder created those tables. It did not revoke anything.
Why does this hit AI-built apps hardest?
Because the failure is invisible and the app works perfectly without the fix. Nothing in your workflow ever asks the question that would surface it.
You asked for a feature. The model wrote a table and the code to read from it. Nobody asked "should a signed-out visitor be able to insert rows into this", so nobody answered it, and the default answer is yes.
Then somebody tells you to turn on RLS. You run the one line, you add a policy that reads using (auth.uid() = user_id), and every guide you can find says you are finished. The dashboard stops warning you. The app still works. The insert grant is still there.
There is a second trap in that same policy, and it is worth knowing before you write twenty of them. auth.uid() returns null when there is no signed-in user, and in SQL null = user_id is never true. So the policy does not error for an anonymous visitor. It silently matches nothing, which looks identical to working correctly right up until the moment it does not. Supabase recommends writing it out:
using ( auth.uid() is not null and auth.uid() = user_id )
What are the four steps that actually close it?
Straight from the Supabase guide, in order. Run these for every table in an exposed schema.
1. Enable RLS.
alter table public.reports enable row level security;
2. Revoke the grants that were handed out automatically. This is the step everyone skips.
revoke all on table public.reports from anon, authenticated;
3. Grant back only what the role actually needs.
-- Signed-in users manage reports. Signed-out visitors get nothing.
grant select, insert, update, delete on table public.reports to authenticated;
4. Write a policy per operation. Not one for all policy. Postgres will not accept multiple operations in one for clause anyway, and a combined rule hides which operation each part was meant to cover.
create policy "Users can view their own reports."
on public.reports for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own reports."
on public.reports for insert
to authenticated
with check ( (select auth.uid()) = user_id );
The with check on the insert is doing real work. Without it a signed-in user can create a row that claims to belong to somebody else.
What about views?
Views are the trap nobody warns you about, and they are common in AI-built apps for a specific reason. A model reaches for a view whenever you ask it to join two tables for a dashboard, so if you have ever asked for a summary screen, you probably have one.
Views bypass RLS by default. Postgres creates them as security definer, which means the view runs with the privileges of whoever created it, normally the postgres superuser. A view over a table you carefully protected will hand out every row those policies were written to withhold.
On Postgres 15 and above, fix it at creation:
create view report_summary
with (security_invoker = true)
as select ...
If you have views in your project and you have never seen security_invoker before, check them today. This is the single highest-value thing in this article.
How do I check my own project right now?
Four things, split between the Supabase dashboard and the SQL editor. None of them take longer than a few minutes.
- Table list, RLS column. Anything that says disabled is readable by anyone with your project URL, which is in your frontend code.
- Run
revoke all ... from anon, authenticatedon any table you only ever reach from your own server. If the app keeps working, the grant was never needed. - List your views and check each one for
security_invoker. - Grep your policies for
auth.uid() =without a null check.
If you want to be sure the fix held rather than assume it, Supabase ships a test runner for exactly this. Create a file per table under supabase/tests/, assert allow and deny for each operation and each role, and run supabase test db. One caution from their docs that applies to any test you write here: never prove an allowed write with lives_ok, because it passes when the write matched zero rows. Add returning and assert on the value that comes back.
Is this the whole picture?
No, and it is worth saying so plainly rather than letting the article imply otherwise. Closing this leaves several other things open.
RLS is one layer. It protects your data when it is reached through the Supabase API, which is what an attacker with your project URL will try first. It does nothing about an API key sitting in your frontend bundle, an admin route with no login on it, or a paywall enforced in CSS.
Those are code problems rather than database problems, and they need something that reads the code. That is what Sentrint does: it reads your whole repository and tells you what an attacker would find first, in plain English, with a fix you can paste back into the tool that built the app. A code scan, not a pentest. The first one is free and needs no card.
Start with the four steps above though. They are free, they take an afternoon, and they close the hole that leaks whole tables.