Supabase RLS checker

Run one read-only query in your Supabase SQL editor and paste the result. The checker lists every table with row level security off, every policy that lets anyone in, and the views and functions that skip RLS, each with the SQL that fixes it.

Runs in your browser. Nothing you paste leaves this page.

1Run this query in your Supabase SQL editor

Read-only: it lists your tables, policies, grants, views and security definer functions, and changes nothing. In the dashboard, open SQL Editor, paste it, run it.

Usually just public. Separate several with commas.
Read-only SQL
-- Keybrum RLS check: keybrum.com/tools/supabase-rls-checker
-- Read-only. It reads Postgres's catalog and changes nothing.
-- The schemas your Data API exposes (Project Settings, Data API). Add yours to the list:
with exposed as (select unnest(array['public']::text[]) as nspname),
api_roles as (
  select (select oid from pg_roles where rolname = 'anon') as anon,
         (select oid from pg_roles where rolname = 'authenticated') as authed
),
rels as (
  select c.oid, n.nspname, c.relname, c.relkind, c.relrowsecurity, c.relforcerowsecurity,
         c.reloptions, c.relispartition, pg_get_userbyid(c.relowner) as owner,
         (select e.extname from pg_depend d join pg_extension e on e.oid = d.refobjid
           where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e' limit 1) as extension
  from pg_class c
  join pg_namespace n on n.oid = c.relnamespace
  where n.nspname in (select nspname from exposed)
    and c.relkind in ('r', 'p', 'v', 'm', 'f')
),
tables as (
  select json_agg(json_build_object(
    'schema', r.nspname,
    'name', r.relname,
    'kind', case r.relkind when 'p' then 'partitioned table' else 'table' end,
    'partition_of', (select pn.nspname || '.' || pc.relname from pg_inherits i
                       join pg_class pc on pc.oid = i.inhparent
                       join pg_namespace pn on pn.oid = pc.relnamespace
                     where r.relispartition and i.inhrelid = r.oid limit 1),
    'extension', r.extension,
    'rls_enabled', r.relrowsecurity,
    'rls_forced', r.relforcerowsecurity,
    'anon', json_build_object(
      'select', has_any_column_privilege(ar.anon, r.oid, 'SELECT'),
      'insert', has_any_column_privilege(ar.anon, r.oid, 'INSERT'),
      'update', has_any_column_privilege(ar.anon, r.oid, 'UPDATE'),
      'delete', has_table_privilege(ar.anon, r.oid, 'DELETE')),
    'authenticated', json_build_object(
      'select', has_any_column_privilege(ar.authed, r.oid, 'SELECT'),
      'insert', has_any_column_privilege(ar.authed, r.oid, 'INSERT'),
      'update', has_any_column_privilege(ar.authed, r.oid, 'UPDATE'),
      'delete', has_table_privilege(ar.authed, r.oid, 'DELETE')),
    'columns', (select coalesce(json_agg(json_build_object(
                  'name', a.attname,
                  'type', format_type(a.atttypid, a.atttypmod),
                  'anon_select', has_column_privilege(ar.anon, r.oid, a.attnum, 'SELECT'),
                  'authenticated_select', has_column_privilege(ar.authed, r.oid, a.attnum, 'SELECT'),
                  'authenticated_update', has_column_privilege(ar.authed, r.oid, a.attnum, 'UPDATE'))
                  order by a.attnum), '[]'::json)
                from pg_attribute a where a.attrelid = r.oid and a.attnum > 0 and not a.attisdropped),
    'owner_columns', (select coalesce(json_agg(a.attname), '[]'::json)
                      from pg_constraint k
                      join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any (k.conkey)
                      where k.conrelid = r.oid and k.contype = 'f' and k.confrelid = to_regclass('auth.users')),
    'before_update_triggers', (select coalesce(json_agg(t.tgname), '[]'::json) from pg_trigger t
                               where t.tgrelid = r.oid and not t.tgisinternal and t.tgtype & 19 = 19),
    'policies', (select coalesce(json_agg(json_build_object(
                   'name', p.policyname,
                   'command', p.cmd,
                   'roles', p.roles,
                   'permissive', p.permissive = 'PERMISSIVE',
                   'using', p.qual,
                   'with_check', p.with_check)
                   order by p.policyname), '[]'::json)
                 from pg_policies p where p.schemaname = r.nspname and p.tablename = r.relname)
  ) order by r.nspname, r.relname) as j
  from rels r cross join api_roles ar
  where r.relkind in ('r', 'p')
),
views as (
  select json_agg(json_build_object(
    'schema', r.nspname,
    'name', r.relname,
    'kind', case r.relkind when 'v' then 'view' when 'm' then 'materialized view' else 'foreign table' end,
    'owner', r.owner,
    'extension', r.extension,
    'security_invoker', coalesce((select lower(split_part(o, '=', 2)) in ('true', 'on', 'yes', '1')
                                  from unnest(r.reloptions) o
                                  where split_part(o, '=', 1) = 'security_invoker' limit 1), false),
    'reads_auth_users', exists (select 1 from pg_rewrite w
                                join pg_depend d on d.classid = 'pg_rewrite'::regclass and d.objid = w.oid
                                where w.ev_class = r.oid and d.refobjid = to_regclass('auth.users')),
    'anon_select', has_table_privilege(ar.anon, r.oid, 'SELECT'),
    'authenticated_select', has_table_privilege(ar.authed, r.oid, 'SELECT')
  ) order by r.nspname, r.relname) as j
  from rels r cross join api_roles ar
  where r.relkind in ('v', 'm', 'f')
),
funcs as (
  select json_agg(json_build_object(
    'schema', n.nspname,
    'name', p.proname,
    'args', pg_get_function_identity_arguments(p.oid),
    'returns', pg_get_function_result(p.oid),
    'trigger', p.prorettype in ('trigger'::regtype, 'event_trigger'::regtype),
    'search_path_set', exists (select 1 from unnest(coalesce(p.proconfig, '{}'::text[])) s where s like 'search_path=%'),
    'extension', (select e.extname from pg_depend d join pg_extension e on e.oid = d.refobjid
                  where d.classid = 'pg_proc'::regclass and d.objid = p.oid and d.deptype = 'e' limit 1),
    'anon_execute', has_function_privilege(ar.anon, p.oid, 'EXECUTE'),
    'authenticated_execute', has_function_privilege(ar.authed, p.oid, 'EXECUTE')
  ) order by n.nspname, p.proname) as j
  from pg_proc p
  join pg_namespace n on n.oid = p.pronamespace
  cross join api_roles ar
  where n.nspname in (select nspname from exposed) and p.prosecdef and p.prokind = 'f'
)
select json_build_object(
  'keybrum_rls_check', 1,
  'server_version_num', current_setting('server_version_num')::int,
  'schemas', (select json_agg(nspname) from exposed),
  'roles_found', (select json_build_object('anon', anon is not null, 'authenticated', authed is not null) from api_roles),
  'tables', coalesce((select j from tables), '[]'::json),
  'views', coalesce((select j from views), '[]'::json),
  'functions', coalesce((select j from funcs), '[]'::json)
) as keybrum_rls_check;

2Paste the result

Copy the single value the query returns. No database at hand? Paste your create table and create policy SQL instead, for a static read.

Paste the result to see who can reach your data.

Each problem comes with what a stranger can do and the SQL that fixes it.

  • Tables with row level security off
  • Policies open to anyone, or to anyone who signs up
  • Users who can change their own role or plan
  • Views and functions that skip RLS

What the Supabase RLS checker looks at

Row level security (RLS) is the Postgres feature that decides, row by row, what each request may read or write. In Supabase it is the only thing standing between your anon key, which ships in every visitor's browser, and your data. The query above reads Postgres's own catalog for the schemas your Data API exposes and returns one JSON value: tables, policies, grants, views and security definer functions. No row of your data is in it. This page reads that value in your browser and runs these checks.

CheckWhy it mattersUsual severity
RLS off on a table the API can reachPostgres then checks only privileges. If anon has them, anyone with your anon key reads and writes every row.Critical
A policy whose condition is true for anon or publicPermissive policies add up: one open policy opens the table even when another checks the owner.Critical to medium
A policy for authenticated that never compares a column to auth.uid()Anyone who signs up gets the authenticated role, so every stranger with an account passes.High
Users who can update their own role, is_admin, plan or creditsAn update policy says whose row it is, not which columns may change.High
A policy that reads user_metadataUsers can edit their own user_metadata from the browser.High
A view without security_invokerA view runs as its owner, usually postgres, and skips the RLS of the tables it reads.High
A security definer function that anon can executeIt runs with its owner's rights and is served at /rest/v1/rpc/.High
An update policy without with checkPostgres reuses the USING condition, so this is a readability issue more than a hole.Low

It also catches partitions with RLS off (a partition does not inherit its parent's policies, so naming it directly skips them), materialized views the API can read, and views that hand auth.users to the API.

How to read the result

The verdict comes first, colored by the worst finding. Critical means a stranger holding only your anon key can reach data today. High means strangers, or anyone who signs up, can reach data they should not see. Medium covers things to check, like a table anyone can read that may be public on purpose. Each finding names the object, says what a stranger can do and gives the SQL that fixes it. Copy all fixes puts them in one script, most severe first, ready for the SQL editor or for your coding agent.

The checker reads grants as well as policies. A table with RLS off that anon and authenticated have no privilege on is reported as low, because nothing reaches it through the API today. That case is more common since 30 October 2026: Supabase stopped granting API access to new tables in public automatically, while older tables kept their grants. Watch the usual quick fix for the resulting 42501 permission denied error: a grant ... to anon on a table with RLS off opens it to everyone.

How to fix what it finds

Every finding comes with its own fix, written for your table and column names. These are the four patterns behind most of them.

Turn RLS on and add owner policies

sql
alter table public.invoices enable row level security;

create policy "invoices: owner reads" on public.invoices
  for select to authenticated
  using ((select auth.uid()) = user_id);

With RLS on and no policy, the table is closed to both API roles, a safe state while you write the policies your app needs. Writing (select auth.uid()) instead of auth.uid() lets Postgres evaluate it once per query rather than once per row, as Supabase recommends.

Replace policies that let everyone in

sql
drop policy "Enable read access for all users" on public.messages;

create policy "messages: parties read" on public.messages
  for select to authenticated
  using ((select auth.uid()) in (sender_id, recipient_id));

Keep using (true) only where every row and every column is meant to be public, like published posts. For a public form such as a waitlist, an open insert policy is a choice: the checker then suggests closing reads and limiting the columns anon may fill.

Stop users from promoting themselves

sql
revoke update on public.profiles from authenticated;
grant update (username, avatar_url) on public.profiles to authenticated;

Signed-in users can then change only the listed columns. role changes only from your server with the service role key, or from the SQL editor.

Close views and functions that skip RLS

sql
alter view public.project_stats set (security_invoker = true);

revoke execute on function public.get_user_id_by_email(text) from public, anon;

Revoke from both roles. Supabase's default privileges grant execute to anon directly, so revoking from public alone can leave the function callable. The Supabase RLS guide has policies for teams, shared rows and admin roles.

What a static check cannot tell you

This checker reads what your database says. It does not send a single request to your project, so be clear about what it misses.

  • Inside functions. A policy that calls is_team_member(team_id) is only as strict as that function, and the checker cannot read its body.
  • Your data. A correct policy still leaks if the app writes the wrong id into the owner column.
  • Storage, Realtime and Edge Functions. Buckets have their own policies on storage.objects. The Supabase storage guide covers them.
  • Anonymous sign-ins. If you turned them on, those visitors get the authenticated role too, so a policy that only checks for a signed-in user lets them in without an email.
  • Pasted SQL. A static read of migrations cannot see dashboard changes, later migrations or the grants your project really has. Run the query for the real state.

Supabase's own Security Advisor, under Advisors in the dashboard, flags tables with RLS off, always-true policies, security definer views and functions anon can call. Use both: the advisor runs on every project, and this checker explains each finding in plain words, catches policies that trust any signed-up user and columns users can promote themselves with, and reads migrations before they ship. For the rest of your stack, start with the Supabase security guide.

Questions

How do I check if RLS is enabled on my Supabase tables?

Run the read-only query on this page in the SQL editor and paste the result: every table with RLS off is reported with the SQL that turns it on, as critical when the anon role can reach it. The Security Advisor in the Supabase dashboard flags them too. Check partitions as well, since they do not inherit RLS from their parent.

What does a Supabase RLS policy with using (true) do?

It lets every role the policy applies to read every row, or write any row for insert, update and delete policies. Without a to clause the policy applies to public, which includes anon, so anyone with your anon key passes. Keep it only for data meant to be public.

Is a Supabase RLS policy for authenticated users enough?

Not on its own. Anyone can sign up to your app and get the authenticated role, so a policy like to authenticated using (true) lets every stranger with an account read every row. Compare a column to auth.uid() so each user reaches only their own rows. The IDOR guide explains the pattern.

Does the service role key bypass Supabase RLS policies?

Yes. The service_role role has the BYPASSRLS attribute, so RLS policies never apply to it. That is why the service role key belongs on your server only, and why the anon key guide treats a leaked service role key as an emergency.

Why does my Supabase query return an empty array after enabling RLS?

With RLS on, a request sees only the rows a policy allows, and with no policy at all it sees none. Add a select policy for the role your app uses. If you get a 42501 permission denied error instead, the role has no grant on the table, which is a separate layer from RLS.

Is this Supabase RLS checker safe to run on a production database?

Yes. The query only reads Postgres's catalog and changes nothing, and its result holds table names, policy text and grants, not your rows. The analysis runs in your browser: nothing you paste is sent to Keybrum.

More free tools

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub