Supabase RLS policy checker (free, runs in your browser)
By Pritesh Ghodge, security engineer · Last updated 1 November 2026
"RLS enabled" isn't the same as protected. A policy like with check (true) on INSERT, or using (true) on UPDATE, keeps RLS switched on while letting anyone in that role write any row. Outside scanners (including ours) can only safely test reads. This checker reads your policies directly, so it also catches the write holes. Two steps, about a minute, no sign-up.
Step 1: run this in the Supabase SQL editor
Read-only. It reads catalog metadata (table names, RLS flags, column names, policies and grants), never rows from your tables.
-- Abbi Labs Supabase RLS policy checker (read-only; reads catalog metadata only, never row data)
with sch as (
select n.oid, n.nspname from pg_namespace n
where n.nspname not in ('pg_catalog','information_schema','pg_toast','auth','storage','realtime','_realtime',
'extensions','graphql','graphql_public','pgsodium','pgsodium_masks','vault','supabase_functions',
'supabase_migrations','net','cron','pgbouncer','_analytics','pgmq','topology','tiger','tiger_data')
and n.nspname not like 'pg\_%'
)
select json_build_object(
'tool', 'abbilabs-rls-checker-v1',
'tables', coalesce((
select json_agg(json_build_object(
'schema', s.nspname, 'table', c.relname, 'kind', c.relkind::text,
'rls', c.relrowsecurity, 'force_rls', c.relforcerowsecurity,
'security_invoker', coalesce(c.reloptions::text[] && array['security_invoker=true','security_invoker=on','security_invoker=1'], false),
'columns', (select coalesce(json_agg(a.attname order by a.attnum), '[]'::json)
from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped)
) order by s.nspname, c.relname)
from pg_class c join sch s on s.oid = c.relnamespace
where c.relkind in ('r','p','v','m')
), '[]'::json),
'policies', coalesce((
select json_agg(json_build_object(
'schema', p.schemaname, 'table', p.tablename, 'policy', p.policyname, 'permissive', p.permissive,
'roles', p.roles, 'cmd', p.cmd, 'qual', p.qual, 'with_check', p.with_check
) order by p.schemaname, p.tablename, p.policyname)
from pg_policies p where p.schemaname in (select nspname from sch)
), '[]'::json),
'grants', coalesce((
select json_agg(json_build_object(
'schema', g.table_schema, 'table', g.table_name, 'grantee', g.grantee, 'privilege', g.privilege_type
) order by g.table_schema, g.table_name, g.grantee, g.privilege_type)
from information_schema.role_table_grants g
where g.grantee in ('anon','authenticated','PUBLIC') and g.table_schema in (select nspname from sch)
), '[]'::json)
) as rls_report;Step 2: paste the result (the single rls_report cell)
Runs entirely in your browser. The JSON you paste is analysed on this page and never sent to Abbi Labs or anyone else. No sign-up.
What it flags
- Critical: RLS is off on a table that
anonorauthenticatedhas grants on. Every row is readable (and writable, per the grants) with your public key. - High: INSERT with
with check (true); UPDATE whose new-row check is open (with check (true), or no WITH CHECK while USING istrue); FOR ALL withtrue; SELECT, UPDATE or DELETE withusing (true)foranonorpublic. - Medium: the same
using (true)forauthenticatedonly (any signed-in user, and sign-up is usually open); views readable by API roles that run with the owner's rights (nosecurity_invoker); and, labelled review, policies on tables with auser_id/owner_id-style column whose condition never mentionsauth.uid()orauth.jwt(). - Low:
anonholding INSERT/UPDATE/DELETE grants; user-scoped policies writtento publicinstead ofto authenticated. - Info: RLS on with no policies (locked to everyone except service_role); RLS off but not granted (one GRANT away from open); policies with no expression for their operation (they allow nothing); UPDATE without WITH CHECK (falls back to USING).
If a role has no grant for the operation a risky policy covers, the finding is lowered one level and says so: the Data API can't use it yet, but it opens the moment someone adds the GRANT, for example while fixing a 42501 permission denied error.
How Postgres RLS actually decides
- Grants first, then RLS. A role needs a table privilege (since Supabase's 30 October 2026 change, new tables don't get one automatically). RLS then filters which rows it can touch. With RLS off, the grant alone decides.
- Permissive policies OR together. If any permissive policy for that role and command passes, the row is allowed. One
truepolicy beats every careful one next to it. Restrictive policies AND on top and can only narrow access. - SELECT and DELETE use
USINGto decide which existing rows are visible. - INSERT uses only
WITH CHECK, on the new row. - UPDATE needs
USINGto see the row it changes andWITH CHECKfor the new version. If WITH CHECK is missing, Postgres applies USING to the new row too, sousing (auth.uid() = user_id)alone still stops users handing a row to someone else, whilewith check (true)doesn't. An UPDATE with a WHERE clause also needs a SELECT policy to find the rows. - No policy = no access when RLS is on, except for the table owner (unless
FORCE ROW LEVEL SECURITY) and roles with BYPASSRLS, such asservice_role. - roles = {public} means every role, including
anon. For signed-out requestsauth.uid()is null, soauth.uid() = user_idpasses nothing, butto authenticatedstates the intent.
Limits (read before trusting a clean result)
- It reads policy expressions as text. It can't see inside functions they call (for example
is_admin()orsecurity definerhelpers), which may allow more than they appear to. - It can't see your app's server code, edge functions or anything using the
service_rolekey, which bypasses RLS entirely. - The "review" heuristic guesses ownership from column names. It can be wrong both ways: tenant-scoped tables (org_id) or public tables may be fine.
- Storage buckets (
storage.objects) and other Supabase-managed schemas are skipped. Column-level grants are not analysed. - Always confirm with a real test: signed out with the anon key, then as two different signed-in users.
Now check what your live app actually exposes
The free Abbi Labs quick check finds your Supabase URL and public key in your site's JavaScript and tests whether common tables return rows to a signed-out visitor. It also looks for secret keys in your bundles, exposed .env and .git files, and missing security headers. It tests reads only, never writes, which is why this checker exists for INSERT and UPDATE policies. No sign-up.
FAQ
Does the checker upload my schema or data?
No. The SQL reads only catalog metadata (table and column names, RLS flags, policy expressions and grants), never rows. The JSON you paste is analysed by JavaScript on this page; it isn't sent to Abbi Labs or anyone else.
Is the SQL safe to run on production?
Yes. It's a single SELECT over pg_class, pg_attribute, pg_policies and information_schema.role_table_grants. It doesn't write, lock tables or read table contents.
Why is a policy with no WITH CHECK on INSERT reported as 'allows nothing'?
INSERT policies use only WITH CHECK. A permissive INSERT policy created without one doesn't let any row through (we verified this on Postgres 17), so it isn't a hole; it's usually a mistake that blocks inserts. The hole is WITH CHECK (true).
What can't it see?
The body of functions called from policies (including security definer helpers like is_admin()), your app's server code and edge functions, data in the rows, and anything the service_role key does (it bypasses RLS). A clean result means no obvious policy holes, not that the app is secure.