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.
| Failure | What it looks like | Why it's missed |
|---|---|---|
| 1 · RLS off entirely | Table is fully readable, and usually writable, by anyone with the public key | The app works perfectly. Nothing errors. The only symptom is data leaving. |
| 2 · RLS on, zero policies | Everything is blocked — including your own app | Safe 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 policy | USING (true) — RLS is on, the badge is green, and the policy allows everyone | The most dangerous of the five. Every dashboard indicator says configured. Often generated during debugging and never tightened. |
| 4 · Policy on SELECT only | Reads are protected; INSERT, UPDATE and DELETE are not | You 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 RLS | A SECURITY DEFINER function runs with the owner's rights, ignoring policies entirely | Invisible 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.
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.
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:
cmd — which operation the policy covers: SELECT, INSERT, UPDATE, DELETE, or ALL. A table with a policy only for SELECT has no rule governing writes. That's failure 4, and it's the single most common real-world gap.qual — the condition deciding which existing rows are visible. If it reads true, the policy permits everyone. That's failure 3.with_check — the condition on rows being written. null on an INSERT or UPDATE policy means no constraint on what gets written.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.
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.
Everything above reads configuration. This one tests reality, from outside, exactly as an attacker would.
supabase.co. You'll find a project URL and a long anon key. Both are public by design — that part is fine.https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*&apikey=YOUR_ANON_KEYAn empty list
[] or a permission error is correct. Real rows means that table is readable by anyone on the internet, right now.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.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 →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.
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.
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.
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.
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.
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.
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.
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.
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.
Broid runs check 3 automatically, using only your app's own public key, and counts rows without ever reading their contents.
Scan my app freeWe also publish the manual version of every check — so you never need us to do it.