I ran a security audit on my own Supabase project last week. RLS was enabled. Three policies per table. The dashboard showed everything green.
Every table was wide open to anyone holding the publishable key.
Here's how that happens, and the 10 checks I now run before every launch.
The trap
The Supabase Table Editor shows you data as postgres — which bypasses RLS entirely. So "I can see the rows in the dashboard, and there are 3 policies" tells you exactly nothing about what the anon role sees. I only found it by querying as anon directly:
set role anon;
select count(*) from agent_performance; -- every row. all of them.
The policies were real. They were also USING (true) — which means "everyone." RLS on ≠ secured.
It got worse on the second project
Different project, 10 tables with RLS disabled outright. The main work_orders table: readable, writable, deletable by anyone with the key. And it was wearing 4 policies that did absolutely nothing — because with RLS off, Postgres ignores every policy on the table. Decorative policies. The dashboard showed them; they enforced nothing.
The 10 checks
Paste the whole thing into the SQL Editor. Every query is read-only — nothing is created, granted, or altered. Any rows = a red flag.
1. RLS enabled on every public table
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
2. No table with RLS on but zero policies
select tablename from pg_tables t
where schemaname = 'public' and rowsecurity = true
and not exists (select 1 from pg_policies p
where p.schemaname = 'public' and p.tablename = t.tablename);
3. No USING (true) / WITH CHECK (true) policies
select tablename, policyname, cmd from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');
4. Every INSERT/UPDATE policy has a WITH CHECK
select tablename, policyname from pg_policies
where schemaname = 'public' and cmd in ('INSERT','UPDATE','ALL')
and with_check is null;
5. No policies on tables with RLS disabled (decorative)
select p.tablename, p.policyname from pg_policies p
join pg_tables t on t.schemaname = p.schemaname and t.tablename = p.tablename
where p.schemaname = 'public' and t.rowsecurity = false;
6. No SECURITY DEFINER functions without pinned search_path
select p.proname as function_name from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef = true
and (p.proconfig is null or not (
select bool_or(split_part(c, '=', 1) = 'search_path')
from unnest(p.proconfig) as c));
7. No SECURITY DEFINER views in public
select c.relname as view_name from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v'
and c.reloptions::text ilike '%security_definer%';
8. anon can't reach what it shouldn't (spot-check your main tables)
-- run as anon in the SQL Editor, or curl with your anon key:
-- curl "https://<ref>.supabase.co/rest/v1/<table>?select=*" -H "apikey: <ANON_KEY>"
-- If it returns rows you expected to be private, RLS isn't doing its job.
9. Oct 30 ready: tables have Data API grants
select tablename from pg_tables t
where schemaname = 'public'
and not has_table_privilege('anon', format('public.%I', tablename), 'SELECT');
-- ^ lists tables anon can't touch at all; if your app needs one, grant it before Oct 30
10. Default privileges set for future tables
select count(*) from pg_default_acl d
join pg_namespace n on n.oid = d.defaclnamespace
where n.nspname = 'public';
-- 0 rows = every table created after today needs a manual GRANT. Set it once.
Two things I'd do differently
1. Policies first, RLS second, tighten third. The naive fix for "RLS off everywhere" is turning it on — which instantly locks out your own app, because RLS-on with zero policies means zero rows for everyone. Write the policies first, enable RLS, then narrow them.
2. Map every policy to its feature before touching it. My first instinct was dropping the anon write policies. That would have killed the CSV upload, which writes to Supabase as part of the flow. Security fixes that break the product get reverted. Document the tradeoff instead.
Oct 30
Supabase stops auto-exposing new tables to the Data API on Oct 30, 2026 — new tables without explicit grants 42501. Check 9 above tells you if you're ready. (It's a grants change, not an RLS change — your policies work exactly the same.)
If Supabase's final-notice email landed in your inbox this week, this is the change it's warning you about.
The full kit
Everything above runs free in your SQL editor — that's the whole point. Locked Down is for when you want the write-ups, the fix playbook, and the two full audits with the wrong calls included: $29 until Oct 30: https://tavora2.gumroad.com/l/lsoerp
Scope note: this is a review aid for projects you own or are authorized to test — not a penetration test, not a certification.
Top comments (4)
Check 7 may miss the common case. Postgres views run with their owner's rights by default, so a plain view in public bypasses RLS on the underlying table unless it was created with security_invoker = on (Postgres 15+). The reloptions filter on security_definer only catches views that set it explicitly. Flipping it to "views where reloptions does not contain security_invoker=true" would surface the real exposures. Check 3 has a similar gap: qual = 'true' misses always-true variants like auth.uid() IS NOT NULL, which lets any self-signed-up user read every row. Did your anon test also run as a second authenticated user to catch cross-tenant reads?
Check 7 can come back empty on a leaky project: Postgres views run with the owner's privileges by default, and there's no security_definer reloption to match. The flag that matters is security_invoker=true (Postgres 15+), so list views whose reloptions don't contain it. A postgres-owned view over an RLS table skips the policies otherwise.
The check that would have saved me is number 8 done as a habit, not a one-off: I query as anon before every deploy, because the dashboard view as postgres hid exactly this gap from me once. One addition I would make is testing an authenticated user from a different tenant, not just anon, since a policy that blocks anon but leaks across tenants passes your list and still fails in production. Do you run these checks in CI, or only by hand before launch?
Really useful write-up, the "set role anon" test is the one most people never run. Two more checks I would add to your list: views, which skip RLS unless you set security_invoker = true, and storage buckets, which are often public by default for things like uploads. Also test with a second signed-in user, not only anon, since "authenticated can read everything" is the other common leak.