LEAKCADE

How to test Supabase RLS before someone else does

Updated 8 min read

To test Supabase RLS, call your own project's REST API with only the anon key. Try to read, insert, update and delete rows, first signed out, then as one user touching another user's rows. Having RLS switched on doesn't prove your data is safe. A loose policy can leave every row open.

QUICK CHECKLIST

  • Call every table with only the anon key, signed out
  • Try select, insert, update and delete on another user's rows
  • Search policies for using (true) and auth.role() with no owner check
  • Call your views and RPC functions the same way
  • Run the Security Advisor and fix 0013 and 0008 findings
  • Add pgTAP tests and run supabase test db in CI

RLS on does not mean your data is safe

Row Level Security (RLS) is a Postgres feature that filters every query. Supabase serves your tables over a REST API, and your anon key ships in your frontend code. So the policies on each table are the only thing between a stranger and your rows. Is the Supabase anon key safe to expose explains why the key can be public.

Postgres runs two checks. Grants decide whether a role such as anon or authenticated can touch a table at all. Policies decide which rows that operation reaches.

On existing projects, a new table in public starts with every privilege granted to anon, authenticated and service_role, so grants rarely stop anyone. Not every project does this, so check yours.

RLS switched on can still be wide openCompares a table with a using (true) policy, where anyone reads every row, with an auth.uid() policy, where each user sees only their own rows.RLS ON, STILL OPEN1RLS switched on2Policy: using (true)3Anyone reads every rowRLS ON, TIGHT1RLS switched on2Policy checks auth.uid()3Users see only own rowsRLS ON, STILL OPEN1RLS switched on2Policy: using (true)3Anyone reads every rowRLS ON, TIGHT1RLS switched on2Policy checks auth.uid()3Users see only own rows
The policy expression decides what RLS lets through.

A table can be in one of three states:

Tables made in the Dashboard get RLS enabled by default. Tables from the SQL Editor or other tools need enable row level security written out, so check every table an AI tool created.

How a loose Supabase RLS policy leaks data

Two patterns cause most of the damage. The first is using (true). A policy with no to clause applies to every role, including anon. On projects where anon still holds the default table grants, it hands every row to anyone holding your anon key.

The second is a policy that only asks whether someone is signed in, such as auth.role() = 'authenticated'. It confirms a session exists and says nothing about whose row it is. Any signed-in account can then read and edit everyone else's rows. If you enabled anonymous sign-ins, those visitors count as authenticated too.

An attacker needs no special tools. They copy your project URL and anon key from your JavaScript bundle and run the same curl commands you'll run below.

BEFORELoose policies: RLS is on but the table is open
alter table public.notes enable row level security;

-- anyone with the anon key can read every note
create policy "read notes" on public.notes
  for select using (true);

-- any signed-in user can edit any note
create policy "edit notes" on public.notes
  for update to authenticated
  using (auth.role() = 'authenticated');
AFTEROwner-only policies for signed-in users
drop policy "read notes" on public.notes;
drop policy "edit notes" on public.notes;

revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;

create policy "select own" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "insert own" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "update own" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "delete own" on public.notes
  for delete to authenticated
  using ((select auth.uid()) = user_id);
Assumes notes has a user_id column. One policy per operation, and signed-out visitors get no grant at all.

Insert and delete have their own traps, covered in the Supabase insert policy and Supabase delete policy guides. For a longer list of common slips, see Supabase RLS mistakes.

Test Supabase RLS yourself with curl

You test from outside, the way a stranger would. You need your project URL, the anon or publishable key, two test accounts (call them A and B) and at least one row owned by each. Seed those rows first. An empty table returns an empty array whatever the policy says.

Set two variables in your shell:

URL="https://YOUR_PROJECT_REF.supabase.co"
ANON="YOUR_ANON_KEY"

Request 1, signed out. Send only the key. This is everything a visitor has. A safe table returns [] or an error with code 42501 (Postgres's permission denied code). A leaking table returns rows.

curl -s "$URL/rest/v1/notes?select=*&limit=5" \
  -H "apikey: $ANON"

Request 2, user A reads user B's rows. Sign in as A to get a token, then ask for B's rows by owner id. The sign-in line needs jq, or copy access_token from the JSON by hand.

TOKEN_A=$(curl -s "$URL/auth/v1/token?grant_type=password" \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{"email":"test-a@example.com","password":"TEST_PASSWORD_A"}' \
  | jq -r .access_token)

curl -s "$URL/rest/v1/notes?select=*&user_id=eq.USER_B_ID" \
  -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A"

Request 3, user A updates user B's row. Always filter by id. An unfiltered PATCH changes every row you can reach. The Prefer: return=representation header makes the API return changed rows, so [] means nothing changed.

curl -s -X PATCH "$URL/rest/v1/notes?id=eq.B_ROW_ID" \
  -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d '{"body":"edited by A"}'

Test writes too. Try an insert that claims to belong to B, then swap -X PATCH for -X DELETE (and drop the body) against B's throwaway row:

curl -s -X POST "$URL/rest/v1/notes" \
  -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d '{"body":"rls-test","user_id":"USER_B_ID"}'

Repeat the writes without the Authorization header to test signed-out access. Then read the results:

Safe resultLeak
1. Signed out reads the table[] or error 42501Rows come back
2. A reads B's rows[]B's rows come back
3. A updates B's row[] (nothing changed)Row returned with A's edit
A inserts a row owned by BError 42501 (RLS violation)New row is created
A deletes B's row[]Row returned, then gone

With Prefer: return=representation, an empty array on a write means the policy filtered the row out.

Views and RPC functions skip your table policies

Views bypass RLS by default, because they are usually created by the postgres user. So a view over a protected table can hand out rows the policies hold back. On Postgres 15 and later you can create a view with (security_invoker = true) so it runs with the caller's permissions. The Supabase security definer view guide covers the fix, and Advisor lint 0010 flags the pattern.

Test a view like a table: call /rest/v1/YOUR_VIEW?select=*&limit=5 with only the apikey header.

RPC functions are Postgres functions you can call over the API, at /rest/v1/rpc/<name>. RLS doesn't apply to the function call itself, so the execute grant decides who can call it. New functions are callable by every role, because Postgres grants execute to PUBLIC by default.

A security invoker function still hits table RLS. A security definer function (one that runs with its owner's rights instead of the caller's) usually skips RLS, so for those the grant is the only gate. Try yours signed out, using a read-only function or a staging project, because the call really runs:

curl -s -X POST "$URL/rest/v1/rpc/YOUR_FUNCTION" \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{}'

If the call succeeds and shouldn't, remove the default access and grant it back only where needed:

revoke execute on function public.your_function() from public, anon;
grant execute on function public.your_function() to authenticated;

Check policies in SQL, then lock them in with pgTAP

curl shows what the live API does. SQL checks policy logic fast, without tokens. The SQL Editor runs as postgres, which bypasses RLS, so a plain select proves nothing. Switch role inside a transaction:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"USER_A_UUID","role":"authenticated"}';

select count(*) from public.notes where user_id = 'USER_B_UUID';  -- expect 0
update public.notes set body = 'x'
  where user_id = 'USER_B_UUID' returning id;  -- expect no rows
rollback;

request.jwt.claims is the setting Supabase reads to learn who is signed in, so these lines fake a login as user A. set local only lasts until the transaction ends, so keep begin and rollback. For the signed-out case, use set local role anon; and run the same queries. To see what you have, list RLS state and every policy:

select tablename, rowsecurity from pg_tables where schemaname = 'public';

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'public' order by tablename;

Once a policy works, turn the checks into a pgTAP file (pgTAP is a testing framework for Postgres) so it keeps working. The Supabase docs ask for one file per table that asserts allow and deny for select, insert, update and delete, for anon and authenticated.

Create it with supabase test new notes_rls.test (file: supabase/tests/notes_rls.test.sql), then run supabase test db. The example covers read, update and insert. Add delete the same way as update:

begin;
select plan(5);

insert into auth.users (id, email) values
  ('11111111-1111-1111-1111-111111111111', 'a@example.com'),
  ('22222222-2222-2222-2222-222222222222', 'b@example.com');
insert into public.notes (id, user_id, body) values
  (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'A note'),
  (gen_random_uuid(), '22222222-2222-2222-2222-222222222222', 'B secret');

-- if anon keeps grants, use is_empty(...) instead of throws_ok
set local role anon;
select throws_ok($$ select * from public.notes $$, '42501', null, 'anon cannot read');

set local role authenticated;
set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';
select results_eq($$ select body from public.notes where user_id = '11111111-1111-1111-1111-111111111111' $$, array['A note'], 'A reads own row');
select is_empty($$ select 1 from public.notes where user_id = '22222222-2222-2222-2222-222222222222' $$, 'A cannot read B');
select is_empty($$ update public.notes set body = 'x' where user_id = '22222222-2222-2222-2222-222222222222' returning 1 $$, 'A cannot update B');
select throws_ok($$ insert into public.notes (user_id, body) values ('22222222-2222-2222-2222-222222222222', 'x') $$, '42501', null, 'A cannot insert as B');

select * from finish();
rollback;

supabase test db runs against your local database, which you start with supabase start. The docs also show a GitHub Actions job that runs it on pushes and pull requests to main.

How to find and fix an RLS leak, step by step

Do these in order. Stop when every curl result matches the safe column in the results table.

  1. Run the Security Advisor

    Open the Security Advisor in Studio, or run supabase db advisors from the CLI. Fix the 0013 and 0008 findings first. Some findings may be intentional.

  2. List your tables and policies

    Run the two SQL queries from the previous section. Every table with RLS off, every policy with using (true) and every auth.role() check with no owner test needs a decision: public on purpose, or fix it.

  3. Run the curl requests

    Do the signed-out read, A reading B and A updating B. Then try insert and delete. Write down each leak as a table plus an operation.

  4. Rewrite the policies

    Enable RLS, revoke the grants you don't need and write policies tied to auth.uid(). Public tables get a select grant only, plus a for select to anon, authenticated using (true) policy. If rows belong to teams instead of single users, read multi-tenant data isolation.

  5. Lock it in with tests

    Add a pgTAP file per table and run supabase test db. After you deploy the migration, run the curl requests against the live project again.

Fix prompt you can paste

Give this to Cursor or Claude Code in the repo that holds your Supabase migrations:

FIX PROMPT
Audit Row Level Security for this Supabase project. Look in supabase/migrations/, supabase/seed.sql and any other .sql files for create table, create policy, create view and create function statements.

Before changing anything, report findings as a list with file, line, table or function name, the operation affected (select, insert, update, delete, execute) and the risk.

1. List every table in the public schema and say whether `enable row level security` is applied. Give file and line for each table without it.
2. Find every policy that uses `using (true)`, `with check (true)`, an `auth.role() = 'authenticated'` check with no owner test, or has no `to` clause. Give file and line and say which operation it affects.
3. Find views without `security_invoker = true`, `security definer` functions in exposed schemas, and functions that never revoke execute from public and anon. Give file and line for each.
4. Propose a NEW migration file (do not edit old migrations) that revokes grants from anon and authenticated, grants back only what is needed, and adds owner-only policies using (select auth.uid()) = user_id, or the column that stores the owner. Ask me which tables are meant to be public before changing them.
5. Write one pgTAP file per table in supabase/tests/ named <table>_rls.test.sql, asserting allow and deny for select, insert, update and delete for anon and authenticated, including user A succeeding on A's own rows and failing on user B's rows.

Then explain each change in plain words. Do not run anything against production.

Leakcade can also scan your deployed app.

Further reading

  1. Supabase docs: Row Level Security
  2. Supabase docs: Securing your API
  3. Supabase docs: Advisors
  4. Supabase docs: Lint 0013 rls_disabled_in_public
  5. Supabase docs: Testing overview
  6. Supabase docs: Advanced pgTAP testing

FAQ

How do I test Supabase RLS policies?

Call /rest/v1/<table> with only your anon key, then again with a token for user A while asking for user B's rows. Try select, insert, update and delete each time. For repeatable checks, use pgTAP files and supabase test db.

Is a Supabase policy with using (true) safe?

Only for data meant to be public, and only for select. A policy with no to clause applies to anon too, so where anon has the default grants, using (true) exposes every row to anyone with your key. Never use it on update or delete.

How do I check if a Supabase table is public with curl?

Send a GET to https://YOUR_PROJECT_REF.supabase.co/rest/v1/<table>?select=*&limit=5 with only the apikey header set to your anon key. If rows come back, anyone can read the table. Do this only on your own project.

Why does my Supabase query return an empty array when rows exist?

Two causes are common. RLS is on with no policy for the role you call as, or a policy's using expression filters every row out, for example user_id does not match auth.uid(). Run the pg_policies query from this guide, then repeat the request signed in as the row's owner.

Can I test RLS in the Supabase SQL Editor?

Yes, but not with a plain query. The editor runs as the postgres role, which bypasses RLS. Inside begin and rollback, run set local role authenticated and set request.jwt.claims to a test user's id.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.