Is my Supabase RLS configured correctly?

Updated 15 August 2026 · Broid — independent security scanning for apps built with AI
Short answer: RLS enabled is not RLS working. The dashboard showing a green "RLS enabled" badge on every table tells you almost nothing — a table can have RLS on and a policy that permits everyone, which is worse than no policy at all because it looks correct. The only way to know is to test from outside, signed out, using the same public key any visitor can read from your page source. Three checks do it: list which tables have RLS on, read the policies and check what they actually allow, then try to read each table as an anonymous stranger. The third one is the only one that can't lie to you.

Why the dashboard isn't the answer

Supabase's own documentation contains the sentence the whole model rests on: your publishable key "is safe to expose with RLS enabled, because row access permission is checked against your access policies and the user's JWT."

Read what that's actually saying. The key in your page source is harmless only to the extent your policies are correct. It is not a secret, it was never meant to be one, and anyone can find it in a few seconds. Everything protecting your data is a set of rules you — or an AI on your behalf — wrote in a dashboard.

The dashboard will happily tell you those rules exist. It won't tell you whether they're right.

The five ways RLS silently fails

FailureWhat it looks likeWhy it's missed
1 · RLS off entirelyTable is fully readable, and usually writable, by anyone with the public keyThe app works perfectly. Nothing errors. The only symptom is data leaving.
2 · RLS on, zero policiesEverything is blocked — including your own appSafe but broken. The classic response is to switch RLS back off to "fix" the app, which converts failure 2 into failure 1.
3 · A permissive policyUSING (true) — RLS is on, the badge is green, and the policy allows everyoneThe most dangerous of the five. Every dashboard indicator says configured. Often generated during debugging and never tightened.
4 · Policy on SELECT onlyReads are protected; INSERT, UPDATE and DELETE are notYou test by trying to read data, it's blocked, you move on. Meanwhile a stranger can delete the table's contents.
5 · A function that bypasses RLSA SECURITY DEFINER function runs with the owner's rights, ignoring policies entirelyInvisible at the table level. The table looks locked down; the door is somewhere else.

Failures 3 and 4 are why "is RLS enabled?" is the wrong question. Both pass that test. Both leak.

Scale check. In a scan of 1,072 live apps built with AI builders and backed by Supabase, 98% had at least one security flaw — and the two most common critical findings were unauthenticated data deletion (172 sites) and unauthenticated data modification (172 sites), ahead of unauthenticated reading (39 sites). That ordering is failure 4, counted in the wild: far more apps protect reads than protect writes. The study was published by a security vendor and skews toward publicly discoverable apps, so treat it as directional — but the shape of it matches what we see.

Check 1 — which tables have RLS on

In the Supabase SQL editor:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

Anything with rowsecurity = false is open to the public key. This is the fastest check and it catches failure 1 — but it is the weakest of the three, because it passes for failures 2, 3, 4 and 5.

Check 2 — read what the policies actually allow

This is the step almost everyone skips, and it's the one that catches the permissive policy:

select schemaname, tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;

Read it like this:

A correct row usually looks like qual: (auth.uid() = user_id) — the signed-in user sees only their own rows. If auth.uid() appears nowhere in your policies at all, that's worth a hard look, because it means nothing is scoped to a user.

And check for functions that bypass all of it

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

Anything returned here is SECURITY DEFINER — it runs with the privileges of whoever defined it and is not subject to your policies. Sometimes that's deliberate and correct. Sometimes it's a door around every wall you just inspected. Either way you should know it exists.

Check 3 — the only one that can't lie to you

Everything above reads configuration. This one tests reality, from outside, exactly as an attacker would.

1
Get your public key. Open your live app, View Page Source, search for supabase.co. You'll find a project URL and a long anon key. Both are public by design — that part is fine.
2
Try to read, signed out. In a private window:
https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*&apikey=YOUR_ANON_KEY
An empty list [] or a permission error is correct. Real rows means that table is readable by anyone on the internet, right now.
3
Try to write, signed out. This is the step people skip, and failure 4 lives here:
curl -X POST "https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"test_column":"rls check"}'
A 401 or 403 is correct. A 201 Created means an anonymous stranger can write to your database. Delete the row afterwards, and only ever run this against your own project.
4
Test between two real users. Create two accounts, sign in as each, and try to fetch the other's records through your app. This catches policies that are scoped but scoped wrongly — and it's the one an external scanner genuinely cannot do for you, because it requires logging in.

Check 3 is the one that can't lie to you — and it's the one Broid automates, using only your app's own public key. Free grade in seconds.

Scan my app free →

Fixing what you find

The standard shape, per table. Note that it grants each operation explicitly rather than using FOR ALL — being explicit is what stops failure 4 recurring:

alter table public.your_table enable row level security;

create policy "read own rows" on public.your_table
  for select using (auth.uid() = user_id);

create policy "insert own rows" on public.your_table
  for insert with check (auth.uid() = user_id);

create policy "update own rows" on public.your_table
  for update using (auth.uid() = user_id)
              with check (auth.uid() = user_id);

create policy "delete own rows" on public.your_table
  for delete using (auth.uid() = user_id);

Enable RLS and add the policies in the same change. Enabling it alone blocks your own app, which is how people end up disabling it again in frustration at midnight — turning a safe-but-broken table into an open one.

Then re-run check 3. A fix you haven't verified from outside is a fix you're assuming.

Why this matters more on AI-built apps

If your app was built with Lovable, Bolt, v0 or a similar tool, there is usually no application server between your user and your database. The browser queries Supabase directly through its REST interface using the public key. In that architecture, a table without a working policy doesn't have weak access control — it has none, and it's reachable by anyone who views your page source.

That's why insufficient RLS in Lovable-generated apps was assigned CVE-2025-48757 at CVSS 9.3 rather than a medium. The architecture converts a configuration mistake into a critical vulnerability. Lovable's own documentation says it plainly: "missing RLS policies are the most common way app data gets exposed."

Apps with a server tier — a Next.js app using server actions, for instance — fail more gently, because the browser can't reach the database at all. We compare the platforms on exactly this.

Common questions

How do I know if my Supabase RLS is configured correctly?

Run three checks. First, select tablename, rowsecurity from pg_tables where schemaname = 'public' to see which tables have RLS on. Second, query pg_policies and read the cmd, qual and with_check columns — a policy covering only SELECT leaves writes unprotected, and qual: true permits everyone. Third, and most important, test from outside: signed out, in a private window, try to read and write each table using your app's public anon key. Only the third check tests reality rather than configuration.

Does "RLS enabled" mean my table is secure?

No. A table can have RLS enabled and a policy of USING (true), which permits everyone — the dashboard shows green and the data is public. A table can also have a policy for SELECT and none for INSERT, UPDATE or DELETE, so reads are protected while a stranger can still write or delete. "Enabled" is a prerequisite, not a verdict.

Is it safe for my Supabase anon key to be public?

Yes, by design — but only conditionally. Supabase's documentation says the publishable key is safe to expose with RLS enabled, because access is checked against your policies and the user's JWT. The key being visible isn't the risk; the key being powerful is. If a table has no working policy, that public key is a full read-write credential for it.

What does USING (true) do in an RLS policy?

It permits every row to every requester the policy applies to. If the policy targets the anon role, it makes the table public. It's frequently generated while debugging — to confirm the app works once access is unblocked — and then never tightened. Because RLS still shows as enabled, it's the failure most likely to survive a review.

Why can people write to my table when reads are blocked?

Because policies are per-operation. A policy created FOR SELECT governs reads only; INSERT, UPDATE and DELETE remain ungoverned unless you create policies for them. This is the most common real-world gap — in the largest published scan of AI-built apps, unauthenticated data deletion and modification were each found on 172 sites, against 39 for unauthenticated reads.

Can a scanner check my RLS for me?

Partly, and it's worth knowing the limits. An external scanner can do check 3 — using your app's own public key, exactly as any visitor could, to test whether tables are readable anonymously. It cannot read your policy definitions, and it does not log in, so it won't tell you whether one signed-in user can read another's rows. Checks 1 and 2 need the SQL editor; check 4 needs two accounts. Everything on this page is doable by hand.

What is SECURITY DEFINER and why does it matter for RLS?

A SECURITY DEFINER function runs with the privileges of the user who defined it rather than the caller, which means it can bypass Row Level Security entirely. Sometimes that's deliberate and correct. But a table with perfect policies can still be reachable through such a function, so it's worth listing them: select proname, prosecdef from pg_proc ... where prosecdef = true.

Test your database exposure now — free, no signup

Broid runs check 3 automatically, using only your app's own public key, and counts rows without ever reading their contents.

Scan my app free

We also publish the manual version of every check — so you never need us to do it.

Sources: Supabase, securing your data · Supabase Row Level Security · Lovable Supabase integration docs · Symbiotic Security, 1,072 apps scanned · GitHub Advisory GHSA-773x-pxjg-gxgx.
Related: Is my Lovable app secure? · Is my AI-built app safe to launch? · Lovable vs Bolt vs v0 security
← All Broid guides
Broid Business Solutions · Terms & Privacy
Supabase, Firebase, Lovable, Bolt, v0 and Cursor are trademarks of their respective owners. Broid is an independent service and is not affiliated with, endorsed by, sponsored by or connected to any company named on this page. All product and company names, logos and brands are the property of their respective owners and are used here for identification and factual comparison only. Comparisons reflect each vendor's publicly available documentation as at 15 August 2026 and may be out of date; verify current behaviour with the vendor. Nothing on this page is legal, compliance or professional security advice, and no automated scan — including ours — guarantees that an application is secure. Corrections: broid@broid.net.