How do I test Supabase RLS with two users?
By StackSiren · Published
Call the REST API signed out, then again with two different users' access tokens. Each call should only return the rows your policies intend — a policy existing in the dashboard is not proof it works.
AI builders (Lovable, Bolt, Cursor) often enable RLS and paste owner-scoped policies. The blank page after enabling RLS is the safe state; an always-true policy is the open door. The only reliable check is to query as the roles your clients actually use. Background: Supabase RLS guide, USING (true) policies, and is the anon key safe?.
Signed-out test (anon role)
You need your project URL and the publishable / legacy anon key only — never the service role. Replace placeholders with your own project.
PROJECT="https://YOUR-PROJECT.supabase.co"
ANON="eyJhbGciOi...your-anon-or-publishable-key"
# No Authorization bearer — PostgREST treats this as the anon role
curl -sS -D- -o /tmp/anon.json \
-H "apikey: $ANON" \
-H "Accept: application/json" \
"$PROJECT/rest/v1/notes?select=id,user_id,title&limit=20"
head -n 20 /tmp/anon.json
What you want:
- Private user data: empty array
[], or401/403if grants denyanonentirely (see 42501 permission denied). - Genuinely public read data: only the public columns and rows you designed for
anon. - Never: another user's private rows, or every row in the table.
If signed-out returns other people's notes, fix policies before you ship. Common cause: RLS off, or to anon using (true) on a private table — covered in the Lovable RLS policies and Bolt RLS checklist posts.
Two-user test (authenticated role)
Create two test accounts in Auth (email/password is fine). Sign each in once to collect a JWT, then query the same table with each token.
# Sign in user A (email grant must be enabled on the project)
TOKEN_A=$(curl -sS -X POST "$PROJECT/auth/v1/token?grant_type=password" \
-H "apikey: $ANON" \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"REPLACE_ME"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
TOKEN_B=$(curl -sS -X POST "$PROJECT/auth/v1/token?grant_type=password" \
-H "apikey: $ANON" \
-H "Content-Type: application/json" \
-d '{"email":"bob@example.com","password":"REPLACE_ME"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
curl -sS -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" \
"$PROJECT/rest/v1/notes?select=id,user_id,title" > /tmp/a.json
curl -sS -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_B" \
"$PROJECT/rest/v1/notes?select=id,user_id,title" > /tmp/b.json
python3 - <<'PY'
import json
a=json.load(open("/tmp/a.json")); b=json.load(open("/tmp/b.json"))
ids_a={r["id"] for r in a}; ids_b={r["id"] for r in b}
print("A rows", len(a), "B rows", len(b), "overlap", len(ids_a & ids_b))
# For owner-scoped notes, overlap should be 0 unless you shared rows on purpose
PY
Expectations for a typical user_id = auth.uid() policy:
| Call | Should see |
|---|---|
| Signed out | Nothing private |
| User A | Only A's rows |
| User B | Only B's rows |
| Overlap of A and B private IDs | Empty |
Also try writes: insert as A with user_id set to B's UUID. A correct with check should reject it. Updates/deletes that touch the other user's id should return zero rows affected or an error — not a silent edit.
Write tests you can re-run
Manual curls are fine once. For a repo you keep, automate them. Supabase documents database testing with pgTAP and also supports hitting the API from CI. A minimal Node script (no extra framework) looks like:
// scripts/rls-smoke.mjs — run against a staging project you own
const PROJECT = process.env.SUPABASE_URL;
const ANON = process.env.SUPABASE_ANON_KEY;
async function rows(token) {
const headers = { apikey: ANON, Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const r = await fetch(`${PROJECT}/rest/v1/notes?select=id`, { headers });
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return r.json();
}
const anon = await rows(null);
if (anon.length !== 0) throw new Error(`anon saw ${anon.length} notes`);
// TOKEN_A / TOKEN_B from a prior password grant stored in CI secrets
const a = await rows(process.env.TOKEN_A);
const b = await rows(process.env.TOKEN_B);
const overlap = a.filter((x) => b.some((y) => y.id === x.id));
if (overlap.length) throw new Error(`A/B overlap: ${overlap.map((x) => x.id)}`);
console.log("rls-smoke ok", { a: a.length, b: b.length });
Run that after every policy migration. Fail the deploy if anon sees private rows or A/B overlap.
SQL role impersonation (Dashboard)
When you want to debug without collecting JWTs, the SQL Editor can impersonate roles. Supabase's RLS docs show setting the JWT claims the policies read:
-- As a superuser / in the SQL Editor:
select set_config(
'request.jwt.claims',
'{"sub":"00000000-0000-4000-8000-0000000000aa","role":"authenticated"}',
true
);
set local role authenticated;
select id, user_id, title from public.notes;
reset role;
select set_config('request.jwt.claims', '', true);
Repeat with Bob's UUID in sub. Compare row sets. Use set local role anon and clear claims for the signed-out case. This does not replace the REST test — the API also enforces grants and headers — but it is fast when iterating on a policy.
If impersonation returns rows the REST call does not (or the reverse), check grants and whether you queried a view/function that bypasses RLS (security definer functions are a common footgun).
Checklist before you call it done
- RLS enabled on every
publictable the client can reach. - No accidental
using (true)/with check (true)on private tables. - Signed-out REST call returns no private data.
- Two real users see disjoint private row sets.
- Cross-user insert/update/delete fails.
- Storage buckets and RPCs get the same two-user treatment if the app uses them (storage bucket public).
StackSiren's free first scan of a site you own flags obvious client-side and header issues; pair it with these RLS curls for the database. Ongoing monitoring starts at Solo ($29/mo). The Supabase RLS checker is the focused tool page for this cluster.
Related reading
- Supabase RLS fix guide
- Lovable + Supabase RLS policies
- Bolt Supabase RLS checklist
- What does USING (true) mean?
- Supabase 42501 permission denied
- Is the Supabase anon key safe?
FAQ
Do I need the service_role key to test RLS? No. Prefer anon + user JWTs. The service role bypasses RLS, so it cannot prove your policies. Keep it server-only.
Why does the SQL Editor show rows that curl does not?
The Dashboard often runs as a privileged role. Impersonate anon / authenticated with set local role and JWT claims, or test only through REST.
Is one test user enough? No for owner-scoped data. One user cannot reveal cross-tenant leaks. Use at least two.
What about team or org tables? Seed two users in the same org and one outside it. Members should see shared rows; the outsider should not.
Can I trust the Supabase advisors alone? Advisors catch common smells (RLS off, always-true policies). They do not prove your product rules. Keep the two-user REST test.
How often should I re-test? After every policy or grant change, and before each production launch. Add the smoke script to CI if you ship frequently.
Fix guides for this topic
Platform checklists: Lovable · Bolt