Skip to content
StackSiren

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 [], or 401/403 if grants deny anon entirely (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

  1. RLS enabled on every public table the client can reach.
  2. No accidental using (true) / with check (true) on private tables.
  3. Signed-out REST call returns no private data.
  4. Two real users see disjoint private row sets.
  5. Cross-user insert/update/delete fails.
  6. 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.

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.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Lovable · Bolt

Related posts

Sources