Skip to content

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.

Vlad Tkachenko7 min read

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 SecurityThe policyYour appA stranger with your Supabase publishable key
Offirrelevant, none are consultedWorksReads every row
Onnone writtenBrokenReads nothing
OnUSING (true)WorksReads every row
OnUSING (auth.uid() = user_id)WorksReads only their own
The tick under each column does not follow the switch above it. Two of these are switched on and one of those hands over everything.

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 TO clause. 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_…, or service_role in 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.

Your app's screens are not the fence. The same table can show two rows through your app and hand over all six to a request that never opened it.

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 compares auth.uid() to the row's owner column, and confirm your app still loads afterwards.
  • Add the TO clause 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.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

Not sure where your own app stands?

Run a free scan and get a plain-language grade from A to F in about 20 seconds. No account, no card.

Scan your app free

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.