PreflightHow it worksPricingSampleGuides

How to test Supabase RLS with two users

The test people already tell you to run: make user A and user B, put a row in as A, ask for it as B. Here is every step, by hand, then where Preflight (and other tools) run it for you.

The free scan is read-only. Nothing is written to your app. See a sample report →

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

  • ✓A policy that looks right in Studio can still return every row. The only proof is a request that runs as someone else.
  • ✓Do it as three people: signed out, user A, and user A reaching for user B’s rows. Test INSERT and UPDATE, not only SELECT.
  • ✓Supabase’s own testing docs show the same idea with pgTAP and supabase test db, plus a TypeScript client test.
  • ✓Studio impersonation and the RLS Tester preview cover SELECT inside the dashboard. They do not fire writes or hit the key your shipped app uses.

01What “test RLS policies” has to prove

Row level security is a Postgres feature. Supabase’s Data API (PostgREST) runs every request as anon or authenticated and lets RLS decide which rows that role sees. Supabase’s RLS guide is the policy reference. This page is the test.

Four questions, in this order:

  • Signed out. With only the public (anon or publishable) key, does /rest/v1/your_table return rows you did not mean to publish?
  • Cross-user read. Signed in as B, does a select for A’s user_id return A’s rows?
  • Forged write. Can B insert or update a row that claims to belong to A?
  • Self-promotion. Can B change plan, role, credits or is_admin on their own row?

Two traps make a sloppy test look like a pass. First, an empty table and a hidden table both return []. Seed a row as A before you query as B. Second, the SQL Editor runs as postgres, which bypasses RLS. A naked select * from notes in Studio proves nothing. Switch role, or leave Studio and call the API.

The Security Advisor will not do this test for you. Lint 0024 skips SELECT … USING (true). Impersonation and the RLS Tester help for SELECT. Writes and the deployed key still need the steps below.

02Step 1: make user A and user B

Use throwaway accounts, never a real customer. Email + password is the path every snippet on this page uses. Magic-link or Google-only apps need a different token source; Preflight’s Proof Report has the same limit today.

  1. In Studio, Authentication → Users → Add user. Create a@example.test and b@example.test with known passwords. Confirm both emails if your project requires it (or create them already confirmed).
  2. Copy the UUID of each user. You will paste those ids into SQL and into query strings.
  3. If the app creates a profiles row on signup, confirm both rows exist. If it does not, you will insert seed rows in step 2.

Keep the passwords in your password manager for the afternoon, then delete the users when you are done. Do not reuse them on production data you care about: the write tests below will try to insert and patch.

03Step 2: insert a row as A, and one as B

B has to have something to fail to read. A has to have something B should not see. Do this from the API as each user, not as postgres, so you also prove the INSERT policy accepts an owner-matched row.

# token for A
curl -s "https://YOUR_REF.supabase.co/auth/v1/token?grant_type=password" \
  -H "apikey: YOUR_PUBLIC_KEY" -H "Content-Type: application/json" \
  -d '{"email":"a@example.test","password":"A_PASSWORD"}'
# copy access_token from the JSON

curl -s "https://YOUR_REF.supabase.co/rest/v1/notes" \
  -H "apikey: YOUR_PUBLIC_KEY" -H "Authorization: Bearer USER_A_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d '{"user_id":"USER_A_UUID","body":"A private note"}'

Repeat for B with B’s token and B’s uuid. If either insert returns 401/403, your INSERT policy is tighter than the app (good) or the grant is missing (see 42501 after 30 Oct). Fix that before you trust a later empty SELECT.

If you would rather seed in SQL, do it as postgres and then never use those sessions for the test itself:

insert into public.notes (user_id, body) values
  ('USER_A_UUID', 'seed A'),
  ('USER_B_UUID', 'seed B');

04Step 3: test as anon and as two users in SQL

Wrap everything in a transaction and roll back. Supabase’s testing overview uses set local role authenticated plus a request.jwt.claim.sub (or the full request.jwt.claims JSON). Both work; auth.uid() reads the sub claim.

-- signed out: expect 0 on private tables
begin;
set local role anon;
select count(*) from public.notes;
rollback;

-- as A: only A's rows
begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"USER_A_UUID","role":"authenticated"}', true);
select count(*) from public.notes;                 -- A's rows only
select count(*) from public.notes
  where user_id = 'USER_B_UUID';                   -- expect 0
rollback;

-- as B: try to read A's row, then forge one
begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"USER_B_UUID","role":"authenticated"}', true);
select id, body from public.notes
  where user_id = 'USER_A_UUID';                   -- expect 0 rows
insert into public.notes (user_id, body)
  values ('USER_A_UUID', 'forged');                -- expect an RLS error
rollback;

-- as B: try to raise your own plan (new transaction:
-- an error above aborts everything after it)
begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"USER_B_UUID","role":"authenticated"}', true);
update public.profiles set plan = 'pro'
  where id = 'USER_B_UUID';                        -- expect 0 rows / error
rollback;

If the “as B, read A” select returns a row, the SELECT policy is too open. If the insert succeeds, WITH CHECK is missing or is true. If the plan update succeeds, you need a column GRANT, not just a row policy — RLS does not hide columns.

Studio shortcut: pick user B in the role menu (user impersonation) and run the same selects. Turn on the RLS Tester preview if you want to see which policy fired. The Tester is SELECT-only; the insert and update above still need this transaction or the curl in step 5.

05Step 4: the same test with supabase-js

This is the path your Lovable or Bolt.new app already uses. It is also the shape of the TypeScript example in Supabase’s testing overview. Run it against the hosted project with the public key, not the service role.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.URL, process.env.PUBLIC_KEY)

await supabase.auth.signInWithPassword({
  email: 'b@example.test', password: process.env.B_PASSWORD
})

const { data: own } = await supabase.from('notes').select('id, user_id')
console.log('B should only see B', own)

const { data: leaked, error } = await supabase
  .from('notes')
  .select('id, body')
  .eq('user_id', process.env.USER_A_UUID)

console.log('B reading A: expect []', leaked, error)

const { error: forged } = await supabase.from('notes').insert({
  user_id: process.env.USER_A_UUID,
  body: 'forged'
})
console.log('B inserting as A: expect error', forged)

const { error: promo } = await supabase
  .from('profiles')
  .update({ plan: 'pro' })
  .eq('id', process.env.USER_B_UUID)
console.log('B raising own plan: expect error or 0 rows', promo)

A leak looks like this: leaked is an array with A’s row, and error is null. PostgREST does not throw when RLS hides rows; it returns an empty list. That is why “the request succeeded” is not a pass. The pass is “the list is empty, and we know A’s row exists.”

06Step 5: curl with two JWTs, the way an outsider would

SQL proves the policy. Curl proves what the deployed API, with the key in your frontend, actually returns. Use only your project and the two test accounts.

# 1. Tokens
A_JSON=$(curl -s "$URL/auth/v1/token?grant_type=password" \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{"email":"a@example.test","password":"'"$A_PASS"'"}')
B_JSON=$(curl -s "$URL/auth/v1/token?grant_type=password" \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{"email":"b@example.test","password":"'"$B_PASS"'"}')
A=$(jq -r .access_token <<<"$A_JSON")
B=$(jq -r .access_token <<<"$B_JSON")

# 2. Signed out (expect */0 on private tables)
curl -sI "$URL/rest/v1/notes?select=*" \
  -H "apikey: $ANON" -H "Prefer: count=exact" | grep -i content-range

# 3. B reads A (expect [])
curl -s "$URL/rest/v1/notes?select=id,body&user_id=eq.$USER_A" \
  -H "apikey: $ANON" -H "Authorization: Bearer $B"

# 4. B forges a row as A (expect 401 or 403)
curl -s -X POST "$URL/rest/v1/notes" \
  -H "apikey: $ANON" -H "Authorization: Bearer $B" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d "{\"user_id\":\"$USER_A\",\"body\":\"forged\"}"

# 5. B promotes themselves (expect error or unchanged row)
curl -s -X PATCH "$URL/rest/v1/profiles?id=eq.$USER_B" \
  -H "apikey: $ANON" -H "Authorization: Bearer $B" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d '{"plan":"pro"}'

# 6. RPCs: pass A's id as B
curl -s -X POST "$URL/rest/v1/rpc/get_notes_for" \
  -H "apikey: $ANON" -H "Authorization: Bearer $B" \
  -H "Content-Type: application/json" \
  -d "{\"uid\":\"$USER_A\"}"

The content-range on step 2 is the signed-out check the RLS checker guide starts with. A suffix of /0 means a stranger gets nothing. A suffix of /214 means 214 rows are public.

Copy-paste the failing request into a note. That request is the evidence. After you change a policy, run the same line again. If you would rather not mint tokens by hand, the $29 Proof Report does steps 3–6 as two test accounts you type in, and prints the curl with placeholders.

07Step 6: writes the SELECT-only tools skip

The RLS Tester preview will not run these. A green SELECT impersonation can still hide an open INSERT.

Try this as BPassTypical cause of a fail
POST /notes with user_id = A401/403, or 201 with B’s id forced by a trigger (then confirm the stored id is B)INSERT policy missing WITH CHECK, or with check (true)
PATCH /notes?id=eq.A_ROW body {"body":"x"}0 rows / 403UPDATE USING (true), or a second permissive policy ORed in
PATCH /profiles?id=eq.B body {"plan":"pro"}0 rows / 403, and a follow-up GET still shows the old planRLS allows the row; no column GRANT. Restrict with grant update (display_name)
POST /rpc/spend_credits with A’s id403, or a result that does not touch ASECURITY DEFINER + EXECUTE granted to authenticated (Advisor 0029)

After any PATCH that might have landed, read the row back as A (or as postgres) and set it back. Do not leave plan = 'pro' on a test account you later reuse.

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

08Step 7: automate it with pgTAP and supabase test db

Hand tests rot. Supabase’s testing overview and pgTAP guide are the official way to keep the two-user check in CI.

# once
supabase test new notes_rls.test

# every change
supabase test db

A shortened version of their todos example, on notes:

begin;
create extension if not exists pgtap with schema extensions;
select plan(4);

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

set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
select results_eq('select count(*)::int from public.notes', ARRAY[1], 'A sees 1');

select set_config('request.jwt.claims',
  '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
select results_eq(
  $$select count(*)::int from public.notes where user_id = '11111111-1111-1111-1111-111111111111'$$,
  ARRAY[0],
  'B cannot read A'
);
select throws_ok(
  $$insert into public.notes (user_id, body)
    values ('11111111-1111-1111-1111-111111111111', 'forged')$$,
  '42501',
  'B cannot insert as A'
);

select * from finish();
rollback;

Wire it to GitHub Actions with supabase start then supabase test db, as the same docs show. That catches a policy an AI tool rewrote on the next migration. It still does not catch a grant, a view or a function that only exists on the hosted project. Keep one curl against production (or a staging clone) in the release checklist.

09Where Preflight (and other tools) run this for you

You do not need a product for this. The steps above are the whole method. People still pay when they want it re-run after every AI change, or a file they can hand to someone who asked “can another user see our data?”

  • By hand: this page. Two browsers, or the curl. Free.
  • Supabase Studio: impersonation + RLS Tester (SELECT). Free. Impersonation, RLS Tester changelog.
  • pgTAP / supabase test db: free, in CI, on the schema you migrate.
  • Lovable Deep scan: reads code for cross-user access. Free. Does not sign in on the live app. Lovable scan guide.
  • Aikido for Lovable: live agents, including cross-tenant access. $100 per test launch pricing, checked 25 Oct 2026.
  • Preflight Proof Report: $29 once, any builder, two test accounts you create. Writes canary rows, sets privilege fields back, prints the request. Sample.

Several tools run a two-account test; Preflight is one of them. The free scan only does the signed-out half (GET/HEAD). If you want the signed-in half without minting JWTs, that is the paid report — or Aikido, or your own script.

?Questions people ask

  • How do I test Supabase RLS policies?

    Create two users, insert a row as A, then select it as B. Empty array is a pass only if you know A’s row exists. Repeat for INSERT and UPDATE. SQL, supabase-js, curl and pgTAP all work; curl is what the deployed API sees.
  • Why does my RLS test return an empty array?

    Either the policy hid the rows or the table has no rows for that filter. Seed data for both users first. The SQL Editor as postgres will always see rows, which is why it is a bad test.
  • Can I test RLS in the Supabase dashboard?

    Yes, for SELECT. Impersonate a user in the Table Editor, or enable the RLS Tester preview. Writes are out of the Tester. For the live API, use curl with two access tokens.
  • What is supabase test db?

    The CLI command that runs pgTAP files in supabase/tests. Supabase’s testing docs show an RLS suite that switches jwt claims between two users and asserts counts and throws_ok on a forged insert.
  • Does Preflight do this two-user test?

    The free scan does not: it is signed-out GET and HEAD. The $29 Proof Report does, with two test accounts you create. It writes marked canary rows. Aikido and several other tools also run a two-user test.
  • Do I need the service_role key to test RLS?

    No, and you should not put it in a frontend test. Seed rows with each user’s own token, or insert once as postgres in a rolled-back transaction. Preflight refuses a service_role key.

Skip the setup

Preflight runs this test for you

The free scan is GET and HEAD only: it counts what a stranger can read signed out. The $29 Proof Report signs in as two test accounts you create and runs the cross-user reads, forged inserts and privilege-column writes below. It writes a few rows marked preflight-canary-… and deletes them afterwards; anything it cannot delete gets a one-line DELETE in the report. You can do the same test yourself with the curl on this page. Aikido’s Lovable product does a live two-user test at $100 per test (launch pricing, checked 25 Oct 2026).