Supabase RLS checker
How to check that your row level security policies actually hold: in SQL, with curl, and from outside as two real users. With the fix for everything you find.
Free scan: GET and HEAD requests only, after you verify you own the domain. The two-account cross-user test is the $29 Proof Report. Updated 25 Oct 2026
In short
- ✓RLS enabled is not RLS correct. A table can have RLS on and a policy that lets every visitor in.
- ✓Test as three people: someone signed out, user A, and user A reaching for user B’s rows.
- ✓Test writes, not only reads. Most leaks people miss are inserts and updates.
- ✓Views,
security definerfunctions and storage follow their own rules. Check them separately.
01What a Supabase RLS checker has to prove
Your app’s browser code talks straight to your database through Supabase’s API, using a public key that anyone can copy out of the page. Row level security (RLS) is the only thing deciding which rows that request gets. So an RLS check is not “is the switch on?” It is four questions:
- Signed out: can someone with only the public key read or write anything you didn’t mean to make public?
- Cross-user read: can a signed-in user read another user’s rows?
- Forged write: can they insert or update rows that claim to belong to someone else?
- Self-promotion: can they change fields on their own row that you never meant them to touch, like
plan,roleorcredits?
Two facts make this tricky. First, Postgres checks grants before RLS: a role with no grant gets a permission error, while a policy that matches no rows returns an empty result. Second, every request runs as one of three roles: anon (signed out), authenticated (signed in, including anonymous sign-ins) or service_role, which bypasses RLS completely.
02Step 1: find tables with RLS turned off
Tables you create in the Supabase Table Editor have RLS on by default. Tables created by raw SQL, migrations or an AI builder often don’t. Run this in the SQL editor:
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and not rowsecurity;
Every row it returns is readable and writable by anyone holding your public key, unless grants block it. Turn RLS on with alter table public.notes enable row level security;. With RLS on and no policies, the API returns nothing for that table. That is the safe default, and the moment many people paste in using (true) to “fix” their app.
Then check views. A view runs with its owner’s rights by default, which means it skips RLS on the tables underneath:
select c.relname as view_name, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';
Any view without security_invoker=true in its options shows every row to whoever can select from it. Fix it with alter view public.my_view set (security_invoker = true);
03Step 2: read every policy, not just count them
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
qual is the USING condition and with_check is the WITH CHECK condition. Look for these patterns:
| You see | What it really allows |
|---|---|
true with roles {public} or {anon} | Anyone with the public key, signed in or not. |
auth.uid() IS NOT NULL or auth.role() = 'authenticated' | Every signed-in user sees every row. If sign-up is open, that is anyone. |
true with roles {authenticated} | The same. These pass a quick glance because they name a role. |
A condition on user_metadata | Users can edit their own user_metadata, so they can grant themselves the role you check. |
| A strict policy and a loose one on the same table | Permissive policies are combined with OR. The loose one wins. |
04Step 3: test RLS policies as anon and as two users in SQL
The SQL editor runs as the postgres role, which bypasses RLS, so a plain select proves nothing. Switch roles inside a transaction and roll back at the end. Create two test users first and copy their ids from Authentication → Users.
-- signed out
begin;
set local role anon;
select count(*) from public.notes; -- expect 0 unless notes are public
rollback;
-- signed in as user A
begin;
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"USER_A_UUID","role":"authenticated"}', true);
select count(*) from public.notes; -- only A's rows
select count(*) from public.notes
where user_id = 'USER_B_UUID'; -- expect 0
insert into public.notes (user_id, body)
values ('USER_B_UUID', 'forged'); -- expect an RLS error
update public.profiles set plan = 'pro'
where id = 'USER_A_UUID'; -- should fail or change nothing
rollback;
Make sure B actually has rows first. A count of 0 means the same thing for “hidden” and “empty”, which is the most common way these tests lie. For repeatable tests, put the same checks in pgTAP files and run them with supabase test db in CI.
05Step 4: check RLS from outside, the way an attacker would
SQL tests prove the policy logic. They don’t prove what your deployed app exposes: which key ships in the bundle, which functions are callable, what the storage API answers. For that, call the same API your frontend calls. Use only your own project and test accounts.
# signed out, with the public (anon or publishable) key from your bundle
curl -I "https://YOUR_REF.supabase.co/rest/v1/notes?select=*" \
-H "apikey: YOUR_PUBLIC_KEY" -H "Prefer: count=exact"
# content-range: 0-24/1240 means 1,240 rows are visible signed out
# as user A, asking for user B's rows (expect [])
curl "https://YOUR_REF.supabase.co/rest/v1/notes?select=id&user_id=eq.USER_B_UUID" \
-H "apikey: YOUR_PUBLIC_KEY" -H "Authorization: Bearer USER_A_ACCESS_TOKEN"
# as user A, trying to upgrade themselves (expect an error or no change)
curl -X PATCH "https://YOUR_REF.supabase.co/rest/v1/profiles?id=eq.USER_A_UUID" \
-H "apikey: YOUR_PUBLIC_KEY" -H "Authorization: Bearer USER_A_ACCESS_TOKEN" \
-H "Content-Type: application/json" -H "Prefer: return=representation" \
-d '{"plan":"pro"}'
You can copy user A’s access token from the browser’s local storage after signing in (the sb-…-auth-token entry). Repeat the cross-user test for every database function your app calls through /rest/v1/rpc/, passing B’s id as the argument. Set back anything you changed.
06How to fix Supabase RLS policies that leak
One policy per action, scoped to the owner, with the role named:
alter table public.notes enable row level security;
create policy "notes: owner reads" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "notes: owner inserts" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "notes: owner updates" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "notes: owner deletes" on public.notes
for delete to authenticated
using ((select auth.uid()) = user_id);
create index if not exists notes_user_id_idx on public.notes (user_id);
USING vs WITH CHECK: USING decides which existing rows a user can see, update or delete. WITH CHECK decides whether a new or changed row is allowed. If you leave WITH CHECK off an UPDATE policy, Postgres reuses USING as the check, so the common claim that “users can reassign user_id” is usually wrong. Write it anyway: it states your intent and survives the next edit.
Privilege columns: RLS works on rows, not columns. If users may edit their own profile but not their plan, restrict the columns:
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
Functions: a security definer function skips RLS. Check auth.uid() inside it, set search_path = '', or take it off the public API. Postgres grants EXECUTE to PUBLIC by default:
revoke execute on function public.get_notes_for(uuid) from public, anon, authenticated;
Wrapping auth.uid() in (select …) lets Postgres evaluate it once per query instead of once per row, and naming to authenticated skips the policy entirely for signed-out requests. Both are in Supabase’s own RLS guidance.
07What Preflight’s RLS check does (and doesn’t)
Preflight does the outside part of this for you (step 4, plus the two-user test), with no code upload and no service_role key.
- Free scan: finds your Supabase project and public key in your deployed bundle, then sends a HEAD request with
Prefer: count=exactto up to 35 tables to count rows visible signed out. It also reads your auth settings and bucket list. GET and HEAD only, after you verify the domain. - Proof Report ($29): signs in as two test accounts you create and tries the cross-user reads (tables, database functions, storage), a forged-owner insert on tables with an owner column, and changes to privilege fields such as
planorcredits, which it sets back and reads again. It writes only rows markedpreflight-canary-…. - Not tested: updating or deleting another user’s rows, webhooks and Edge Functions. Every report lists what it did not check. See the sample report and every request it makes.
?Questions people ask
Is “RLS enabled” enough to protect a Supabase table?
No. Enabled with no policies blocks the API, but enabled with a policy likeusing (true)orauth.uid() is not nulllets everyone, or every signed-in user, read every row. You have to read the policies and test them.What is the difference between USING and WITH CHECK?
USING filters the existing rows a user can select, update or delete. WITH CHECK validates rows being inserted or the new version of an updated row. INSERT policies only take WITH CHECK. If an UPDATE policy omits WITH CHECK, Postgres uses the USING condition for both.Does the service_role key respect RLS?
No. The service_role key and the newer secret keys connect with a role that bypasses RLS. They belong on a server only. If one is in your frontend, follow the leaked service_role key guide.Why does my RLS test return 0 rows?
Either the policy hides the rows or the table is empty for that user. Those look the same. Insert rows as user B first, then test as user A.Can I test Supabase RLS without writing code?
Partly. The dashboard can run queries as a chosen role, and the RLS tester preview covers SELECT. For writes and for your deployed app, use the SQL in this guide, pgTAP tests, or Preflight’s Proof Report, which tries it as two real accounts.Does Preflight need my service_role key or my code?
No. It refuses a service_role key. The free scan uses only what your site already serves. The Proof Report uses two test accounts you create, and asks you to run one line of SQL so it knows the project is yours.
What an outside scan can't tell you
Can one user read another user's data?
Preflight signs in as two test accounts you create and tries to read one account's rows and files from the other. That cross-user test is the $29 Proof Report for Supabase apps. Start with the free scan: it checks your live app from outside with GET and HEAD requests only.