Security basics
Enable Row Level Security on every Supabase table, then prove it
Enabling Row Level Security in Supabase with no policy locks a table completely. A policy without the setting does nothing. Here is the SQL, and the test.

In short
- Enabling Row Level Security in Supabase with no policy locks a table completely, and writing a policy without enabling the setting does nothing at all. Every table needs both.
- Three policy shapes cover almost everything an AI builder makes: rows that belong to one person, rows anyone may read, and rows your app writes on a visitor's behalf.
- Then check from outside your app with no login, because that is the request a stranger makes, and it is the only one that tells you what your policies do rather than what they say.
You have been told to turn on Row Level Security, or your AI builder mentioned it in passing while fixing something else. Your Supabase project has somewhere between four and forty tables in it, and you do not know which of them are covered.
The instruction you will find everywhere is one line of SQL per table, and it is right as far as it goes. What almost every guide stops short of is the step after it. A policy that exists is not a policy that works, and nothing in your dashboard will show you the difference. So enabling Row Level Security in Supabase is three pieces of work rather than one: switch it on across every table, write the two or three policies that put your app back together, then ask your own database the question a stranger would ask it.
What does enabling Row Level Security actually do?
It makes Postgres consult your rules before it hands over a row. With the setting off there are no rules to consult, so the answer to every request is everything.
Picture a librarian who fetches whatever you ask for. Row Level Security is the instruction to check a note about you before filling the trolley. The note is your policy, and it might say that this person may take the books they wrote, or it might say that anybody may take anything. With the instruction in place and no note written yet, the librarian comes back with an empty trolley and offers no explanation.
That last part is the piece people trip over, and it decides what a test looks like later. Row Level Security filters rows. It does not refuse requests. A table you are not allowed to read answers with an empty list and a success code, not with an error or a login prompt. Your app is never told that it was turned down. It receives nothing, and it renders a blank screen.
So switching the setting on across a project that has no policies in it yet does not lock strangers out of your data. It locks everyone out, your own app included, until you say who is allowed to see what.
How do I enable RLS on every Supabase table at once?
One loop, run once in the SQL Editor. It walks every table in your public schema and turns the setting on wherever it is off.
Start by seeing where you stand. This lists your tables and says whether each one currently has it:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
Every row where rowsecurity reads false is a table handing its contents to
anybody holding the publishable key that ships in your app. If that list is
short, do them one at a time and watch what breaks:
alter table public.orders enable row level security;
If it is not short, this covers the lot:
do $$
declare t record;
begin
for t in
select tablename from pg_tables where schemaname = 'public'
loop
execute format(
'alter table public.%I enable row level security', t.tablename
);
end loop;
end $$;
Run that and your app goes blank. Supabase says so plainly in its own documentation: data becomes inaccessible through the API using a publishable key until policies are defined. It is the setting doing its job, and it is why the next section is the one to have open before you press run.
The three policies you actually need
Almost every table in an app like yours is one of three shapes: rows that belong to one person, rows anyone may read, and rows your app writes on a visitor's behalf. Here is each of them, ready to paste and rename.
Rows that belong to one person. Orders, messages, saved items, anything with an owner.
create policy "read own orders"
on public.orders for select
to authenticated
using ( (select auth.uid()) = user_id );
auth.uid() is the id of whoever is signed in on that request. user_id is
whatever column on your table records the owner, so check the name before you
run it: builders also write owner_id, profile_id and created_by. The
to authenticated line means the policy is never even considered for a
signed-out visitor, which is what keeps the table closed to the public.
Rows anyone may read. A product catalogue, published articles, a map of venues.
create policy "anyone may read products"
on public.products for select
to anon, authenticated
using ( true );
Write this one deliberately or not at all, because it is also the policy an AI builder reaches for the moment you ask it to fix an empty screen. The question to settle first: could this table be a page on your site, exactly as it stands, with nothing taken out? A no means it wants the ownership policy above instead.
Rows your app writes. Reading and writing are separate permissions in Postgres, so a table your app saves to needs a second policy, and this one checks the row on its way in rather than on its way out.
create policy "insert own orders"
on public.orders for insert
to authenticated
with check ( (select auth.uid()) = user_id );
Which clause goes where is the part that catches people, and the update row
carries a requirement that has nothing obvious about it:
| Operation | Clause | Also needs |
|---|---|---|
select | using | |
insert | with check | |
update | both using and with check | a select policy on the same table |
delete | using |
Supabase's documentation is explicit about that last one: without a
corresponding select policy, an update will not work as expected. And if the
message new row violates row-level security policy is what sent you looking in
the first place, the gap between those two clauses is
the whole of that error.
Why every example writes (select auth.uid()) instead of auth.uid()
Because the brackets make Postgres work the value out once for the whole query rather than once for every row it examines.
The wrapped version becomes what Postgres calls an initPlan, which it runs a single time and then reuses for the rest of the statement. Without the brackets the function is called again on row one, row two, row three, and on down a table that might hold a hundred thousand of them. Supabase's own Performance Advisor reports the unwrapped version under a rule named Auth RLS Initialization Plan, and it is one of the commonest entries people find sitting in there.
One caveat, and it is Supabase's own: this works because the answer does not change from row to row. A function whose result genuinely depends on the row in front of it cannot be hoisted out of the loop, so leave that one unwrapped.
While you are here, add an index on the column your policies filter on. The policy becomes a condition on every read of that table, so on a table with a lot of rows in it an unindexed column shows up in your response times:
create index orders_user_id_idx on public.orders (user_id);
Testing a policy without making a fake account
The Supabase SQL Editor can run a query as though a particular visitor sent it, which covers the anonymous case completely.
The editor carries a control for the role a query should run as. Set it to the anonymous role, then run an ordinary select against the table you just changed. What comes back is what a signed-out stranger gets. For a signed-in visitor, pick the id of a user you already have rather than making a new one; any row in your own table is enough to test against.
If you would rather type it than click it, the same thing in SQL:
begin;
set local role anon;
select * from public.orders;
rollback;
The begin and rollback are there so the role change lasts for that block and
no longer, which matters if you are working through several tables in one sitting.
What this tells you is what your policies do inside the database. What it cannot tell you is what your project hands to the internet, because the request your visitors actually make does not start in the SQL Editor. It starts in a browser, carries a publishable key, and arrives through your project's public address.
How do I test Supabase RLS from outside my app?
Send the request a stranger would send. You need two values and both of them are already sitting in the code your site serves to every visitor: your project URL and your publishable key.
curl "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*&limit=1" \
-H "apikey: YOUR_PUBLISHABLE_KEY" \
-H "Authorization: Bearer YOUR_PUBLISHABLE_KEY"
Swap in one table name at a time and read what comes back. An empty list, [],
means the policy held for a signed-out visitor. A row means anyone holding a key
that ships in your app can read that table. An error mentioning the key itself
means you copied the wrong value, which is worth ruling out before you conclude
anything.
On a newer Supabase project that key begins sb_publishable_, and on an older
one it is the anon key. Either is safe to use this way and safe to have in your
app, which is the point of it;
which API keys belong in a frontend
covers the pair that is not, and
where to find them in the dashboard is
the four values on that settings page.
This is the test that matches reality, and running it at scale is how we know how common the gap is. Between 12 and 14 August 2026 we ran nine external checks over 30,998 live apps published from Lovable, Base44, Replit, v0 and Bolt. Of the 3,680 Supabase-backed apps where the check could complete, 2,096 answered an anonymous request with rows from at least one table. That is 57%, and it is a share of the apps that gave us a straight answer rather than of everything we scanned. The full dataset is published, and what that 57% is a share of walks through the counting.
Doing this by hand is fine for four tables and tedious for forty. Our free scan sends that request for you, works out which tables exist without you naming them, and tells you which ones answered, alongside eight other checks it makes from outside. It reads your live site the way any visitor can, takes about 20 seconds and needs no account: scan your app.
Before you rewrite policies on a live table
Take a copy of your database first. You are about to change permissions across every table you have, using the same tools that produced the problem.
Two different risks are worth separating, because only one of them is about the work you are doing today. If a table was readable and writable by anyone, then tightening it now does nothing about what already happened, and that version tends to surface as a support message about data that changed on its own. The other risk is the migration itself. A policy dropped and recreated slightly wrong is an ordinary Tuesday, and the way back is a copy of how things looked an hour before.
On a paid Supabase plan there is last night's copy waiting in the console. On the free plan there is nothing to fall back to at all, because Supabase takes no automatic backups on the free tier. If that is you, take one before you start.
Reeve Care is the version of that you do not have to remember. Your Supabase database is copied on a schedule, kept outside your Supabase account, encrypted, and read back to confirm it restores before the date on your dashboard moves. Uploaded files travel with it once you connect a Storage key, and that key is asked for separately because Supabase issues no read-only key for files: the one that copies your uploads can also write, where the one that copies your database cannot. Connecting it is optional and your database is backed up either way. Backups are Supabase only, so if your data lives elsewhere we say so rather than sell you a subscription watching an empty box.
Restoring is the part that matters on a day like this one. Care copies the current state of your database before it replays the version you picked, so pressing the button has an undo of its own.
The other half is the check you just ran by hand. A policy that loosens during some later migration is not a thing anyone finds by looking, so Care re-runs the same anonymous request on a schedule and tells you when a table starts answering that was quiet last week. Uptime monitoring and a monthly report sit on the same subscription. None of that writes your policies for you, and no backup makes an open table closed. What it changes is how much a bad migration costs you. What Reeve backs up on Supabase, how often, and what a restore does walks the whole cycle, and the plans are on the pricing page.
The order to work in
What to do
- List your tables with
select tablename, rowsecurity from pg_tables where schemaname = 'public'and see how many are open before you change anything. - Take a backup, then enable row level security on every table in the public schema. Expect your app to go blank; that is the setting working.
- Give each table one of the three policies. Ownership is the default;
USING (true)is a decision you make table by table, not a way to get the screens back. - Write
(select auth.uid())rather thanauth.uid(), and index the column your policies filter on. - Test each table from outside with no login. An empty list is the pass. A row is a table anyone can read, whatever your dashboard says about it.
Start with the table that would embarrass you most as a public page, and work down from there. The 10-minute security checklist covers this alongside the rest of what is worth confirming in a newly launched app, and the Supabase safety guide goes through what else tends to get left open.
FAQ
What happens if I enable RLS and write no policy?
The table stops answering, including for your own app. With the setting on, Postgres consults your policies before handing over a row, and with no policies there is nothing that can say yes, so requests come back empty. Supabase documents this directly: data becomes inaccessible through the API using a publishable key until policies are defined. Nothing is deleted and nothing is broken. Write a policy for the rows your app is meant to show and the screens come back.
Does Row Level Security slow my queries down?
It can, and the two fixes are small. Write `(select auth.uid())` in the policy condition, with the brackets, which lets Postgres work the value out once for the whole query and then reuse it for every row. Supabase flags the unwrapped version in its own Performance Advisor, under a rule called Auth RLS Initialization Plan. Then add an index on the column the policy filters on, usually `user_id`. On a table holding a few thousand rows you are unlikely to notice either way. On a large one, both of them matter.
Do I need RLS if my table has no personal data?
You still want it switched on, and the policy can be the permissive one. A table with row level security off is readable by anyone holding the publishable key that ships in your app, which is fine for a product catalogue and much less fine for anything you would not publish as a page. Switching it on and writing a read policy of `USING (true)` gives you that same public access on purpose, and it means the table reads as decided rather than as forgotten the next time somebody goes through the list.
Why did my realtime subscription stop working after I enabled RLS?
Because Realtime consults the same policies before it sends a change to a subscriber. A table with row level security on and no select policy for that visitor delivers no rows to a query and no changes to a subscription, for exactly the same reason. Add the select policy that subscriber needs and the stream resumes. If you are using Realtime broadcast or presence rather than database changes, those are authorized separately, through policies written on the `realtime.messages` table.
How do I test a policy without creating a fake user?
Two ways, and neither one needs a new account. The Supabase SQL Editor can run a query as though a particular role sent it, which covers the anonymous case completely: whatever comes back is what a stranger gets. For the signed-in case you need a user id to stand in for, and any row already in your own table will do. The second way is a request from outside carrying your publishable key, which tests the whole path rather than only the policy.