Security basics
Supabase Row Level Security is on. Your table is still public.
Turning on Supabase Row Level Security does not protect a table. Your policies do, and the policy that fixed your broken app may let everyone in.
In short
- In Supabase, the switch and the rules are two separate things. Switched on with no rule blocks everyone; switched on with the wrong rule blocks nobody.
- The rule that makes a broken app work again is usually the one that allows every request, from anyone.
- Read the policy, not the toggle. One word in it decides whether strangers can read your table.
You turned Row Level Security on because something told you to: the advisor inside Supabase, a checklist, a scan result, somebody in a Discord. The toggle is green in your dashboard now. And you have been told your table is still readable by strangers.
Here is the part that guide after guide gets wrong: the toggle does not protect anything. It decides that your rules get checked. The rules are a separate thing, you have to write them yourself, and the fastest rule to write, the one that makes a broken app work again, lets everybody in.
Row Level Security is on in Supabase. How is my table still public?
Because switching it on and deciding who gets in are two different steps, and only the first one is a toggle.
Think of the toggle as putting somebody at the door. Flipping it does not decide who is allowed through. It decides that somebody is now checking a list. Your policies are the list. An empty list turns everyone away; a list that says "everyone" turns nobody away. Both of those are Row Level Security switched on, and your dashboard shows the same green either way.
That is why the setting on its own answers very little, and why our scanner never asks Supabase whether it is enabled. It asks the table instead. It sends the request a stranger would send, using the public key that ships inside your app, and sees whether an answer comes back. It asks for a count rather than for the rows, so it learns that the door opened without reading anything behind it.
The policy that fixed your app is probably the problem
The moment you switch Row Level Security on, your app stops showing data, and whatever you did next to get it working again is the thing worth looking at.
That sequence is completely normal, and it is where this goes wrong. With the setting on and no policies written, Postgres (the database engine Supabase runs on) refuses every request by default, so your lists come back empty and your screens go blank. Something has to go on the list. If you asked Cursor or Lovable to fix it, or pasted the first snippet that made the error stop, what you have now probably looks like this:
CREATE POLICY "Enable read access for all users"
ON public.profiles
FOR SELECT
USING (true);
USING (true) is the condition a row has to satisfy before the database will
hand it over. Every row satisfies true. There is a second detail in there that
is easy to walk past: with no TO clause, a policy applies to public, and
public covers signed-in visitors and complete strangers alike.
So the app works again, nothing shows an error, and the table is exactly as readable as it was before you started.
That fixes reading, and only reading. If your app also saves to this table, the next thing you meet is new row violates row-level security policy, which is the same setting refusing a write, and no read policy will clear it.
The four states a table can be in
Two of these are safe and two are not, and the toggle does not tell you which
is which. "Publishable key" below is the one that belongs in your app:
sb_publishable_… in newer Supabase projects, anon in older ones.
| Row Level Security | The policy | Your app | A stranger with your Supabase publishable key |
|---|---|---|---|
| Off | irrelevant, none are consulted | Works | Reads every row |
| On | none written | Broken | Reads nothing |
| On | USING (true) | Works | Reads every row |
| On | USING (auth.uid() = user_id) | Works | Reads only their own |
The two middle columns are the pair that catches people. Same setting, same green badge, opposite outcomes, and the difference is one word inside a rule most people never open.
If you would rather not read every policy yourself, our free scan asks your live database the same question a stranger would, and tells you which tables answered. It takes about 20 seconds and needs no account: scan your app.
"Authenticated" is not the same as "yours"
A policy that allows authenticated allows everybody who has an account, which
(if your app has an open sign-up form) is everybody who is willing to fill it
in.
This is the more subtle version of the same mistake, and it survives a lot of
review because it looks careful. TO authenticated USING (true) reads like a
restriction, and it is one: it excludes people who never signed up. What it does
not do is stop one of your customers from reading another customer's rows, which
is usually the thing you meant by "private".
The rule that does that names who the row belongs to:
CREATE POLICY "Users read their own rows"
ON public.orders
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
auth.uid() is whoever is asking. user_id is the column on the row saying who
it belongs to. The row comes back when those two match, and stays where it is
when they do not.
How to check your own tables in two minutes
Open Supabase, go to Authentication → Policies, and read the expression inside each policy rather than the badge next to each table.
Three things to look for:
- A policy whose condition is
true. Decide, table by table, whether you would be comfortable putting that data on a public page. For a list of published articles, yes. For anything with a person in it, no. - A policy with no
TOclause. It applies to everyone, signed in or not, even when the rest of the rule looks specific. - A table with the setting on, no policies at all, and an app that somehow
still works. That combination means something is reaching your data another
way, and the usual explanation is a secret key (
sb_secret_…, orservice_rolein an older project), which ignores every policy you have written. Which API keys are safe in your frontend covers how to tell that key from the safe one.
The other check people reach for is opening the app in a private window without signing in, and it is worth doing, but know what it proves. Your app decides what to render. Your database decides what to hand over. Those are different decisions, and a stranger who skips your screens gets the second one.
What it looks like when this is happening to you
Nothing. That is the shape of this one, and it is the reason it sits in place for months.
No error appears in your builder. Nothing gets slower, no screen breaks, no email arrives. Your app behaves exactly as it did the day you shipped it, because from your app's side nothing changed: it was always allowed to read those rows. What changed is that everybody else is allowed to read them too, using the key that ships in the code your site sends to every visitor.
When it does surface, it surfaces sideways. A customer asks how someone knew
something only your app knew. An address list you never published turns up
somewhere. And if the permissive rule covers writing as well as reading
(FOR ALL rather than FOR SELECT), then rows can be changed and removed by
anyone too, which is the version people discover as a table that is suddenly
empty.
Rules also drift. A migration, a schema change, another late-night fix that needed the data to load: any of them can loosen a policy without saying so, which is why this is worth a second look later rather than one look now. Watching for that is part of what Reeve Care does, though a reminder in your calendar does the same job.
What to do this week
What to do
- Open Authentication → Policies in Supabase and read the condition on every policy, table by table. The badge on the table is not the answer.
- For each table holding people (users, profiles, orders, messages), check the rule names the row's owner rather than allowing everyone.
- Replace any
USING (true)on those tables with a rule that comparesauth.uid()to the row's owner column, and confirm your app still loads afterwards. - Add the
TOclause you meant. A policy without one applies to strangers as well as to signed-in visitors. - If a table has the setting on, no policies, and your app still shows its data, find out what is bypassing it before you touch anything else.
Start with the table that would embarrass you most if it were a public page, and fix that one today. The 10-minute security checklist covers this alongside the other things worth looking at in a newly launched app, and the Supabase safety guide goes through what else tends to be left open.
FAQ
I turned Row Level Security on and my app stopped showing data. Did I break something?
No, that is the setting working. With Row Level Security on and no policies written, Postgres (the database engine underneath Supabase) refuses every request by default, including the ones from your own app. The fix is to add a policy that describes who should see which rows. The mistake to avoid is adding one that allows everybody, because that is the version that makes the app work and leaves the table open.
Is USING (true) ever the right policy?
Yes, for data that is genuinely public. A table of published blog posts, a product catalogue, a list of venues on a map: those are meant to be readable by anyone, and a policy allowing everyone to read them is correct. It stops being correct the moment the table has people in it. Ask yourself whether you would be comfortable posting the contents of that table on a public page, and let the answer decide the policy.
Does Row Level Security protect me if my secret key leaked?
No. A Supabase secret key (sb_secret_ in newer projects, service_role in older ones) bypasses Row Level Security completely, which is what it is for. Every policy you wrote is skipped, on every table. If that key is in your frontend, your policies are not doing anything for you and rotating the key is the first job, ahead of any policy work.
Do I need Row Level Security if my app already has a login screen?
Yes. Your login screen controls your app, and your app is not the only way to reach your database. Supabase gives every project a web address that answers requests directly, and the key needed to talk to it is in the code your app sends to every visitor. Policies are the part that applies no matter which door the request came through.