cd /news/developer-tools/how-to-test-supabase-rls-with-two-us… · home › topics › developer-tools › article
[ARTICLE · art-169864] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

How to test Supabase RLS with two users (script included) By Shaameer

Developer Shaameer published a two-user integration test script that verifies Supabase Row Level Security policies by creating two real users, signing in as each, and confirming that one cannot read or update the other's rows, nor can a logged-out client read the table. The script uses the service-role key only for user creation and cleanup, runs via node --env-file=.env test-rls.mjs, and exits non-zero on failure, offering a faster alternative to pgTAP for JavaScript developers.

by read4 min views2 publishedNov 15, 2026

An RLS policy can look perfect and still leak. Especially with AI writing half our migrations these days, broken permissions are the silent killer of MVPs.

The only way to know if your database is actually locked down is to try to break it. This post shows a two-user integration test you can run in a minute: create User A and User B, sign in as each, and verify that A can’t read or edit B’s data.

It works on any table. I’ll use a standard profiles table with owner-only policies as the example.

Why “it works in my app” proves nothing

When you test your own app, you are always logged in as the same user, looking at your own rows. Of course it works. A leak only shows up when a different user requests your data, and that never happens while you’re building.

While Supabase offers native SQL testing via pgTAP, most JavaScript developers just want a fast, readable script they can drop straight into their repo without writing SQL unit tests. The test is simple: spin up two real users, and have one try to touch the other.

What the test checks

Assume a profiles table with RLS enabled, owner-only policies (auth.uid() = id), and a standard signup trigger that creates a row per user. The test guarantees that:

const url = process.env.SUPABASE_URL;

const anonKey = process.env.SUPABASE_ANON_KEY;

const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // Local/CI test only

const admin = createClient(url, serviceKey, { auth: { persistSession: false } });

async function makeUser(label) {

  const email = `rls-test-${label}-${Date.now()}@example.com`;

  const password = 'Test-password-123!';

const { data, error } = await admin.auth.admin.createUser({

    email,

    password,

    email_confirm: true,

  });

  if (error) throw error;

const client = createClient(url, anonKey, { auth: { persistSession: false } });

  const { error: signInError } = await client.auth.signInWithPassword({ email, password });

  if (signInError) throw signInError;

return { id: data.user.id, client };

}

let failed = 0;

function check(name, ok) {

  console.log(ok ? `PASS  ${name}` : `FAIL  ${name}`);

  if (!ok) failed++;

}

const a = await makeUser('a');

const b = await makeUser('b');

try {

  // 1. A can read their own row

  const own = await a.client.from('profiles').select('*').eq('id', a.id);

  check('A can read own profile', !own.error && own.data.length === 1);

// 2. A cannot read B's row

  const other = await a.client.from('profiles').select('*').eq('id', b.id);

  check('A cannot read B', !other.error && other.data.length === 0);

// 3. A cannot update B's row

  const upd = await a.client

    .from('profiles')

    .update({ full_name: 'hacked' })

    .eq('id', b.id)

    .select();

  const bRow = await admin.from('profiles').select('full_name').eq('id', b.id).single();

check(

'A cannot update B',
    (upd.data ?? []).length === 0 && bRow.data?.full_name !== 'hacked'

  );

// 4. Logged-out client cannot read the table

  const anon = createClient(url, anonKey, { auth: { persistSession: false } });

  const anonRead = await anon.from('profiles').select('*');

  check('anon cannot read profiles', !!anonRead.error || (anonRead.data ?? []).length === 0);

} finally {

  // Cleanup: deleting the auth user also removes the profile via ON DELETE CASCADE

  await admin.auth.admin.deleteUser(a.id);

  await admin.auth.admin.deleteUser(b.id);

}

console.log(failed === 0 ? '\nAll checks passed' : `\n${failed} check(s) failed`);

process.exit(failed === 0 ? 0 : 1);

Add your keys to a local .env file and run:

Bash

node --env-file=.env test-rls.mjs Reading the results

• Check 1 fails (A can’t read own row): You likely have a missing table grant, or your signup trigger failed to create the profile row. A 42501 permission denied error here usually means you are missing GRANT SELECT ON public.profiles TO authenticated;.

• Check 2 or 3 fails (A can touch B): Your policy is too permissive. Check for accidental USING (true) rules, policies granted to authenticated without an auth.uid() owner check, or a missing WITH CHECK clause on updates.

• Check 4 fails (anon can read): You are granting table access to the anon role or have a policy targeting it. Revoke public/anon grants and restrict policies strictly to authenticated.

Pro tip: Verify your test works by temporarily loosening a policy to USING (true) and running the script. Check 2 should immediately fail. A security test that can't fail isn't testing anything.

A quick alternative in the SQL editor

If you just want to debug a policy quickly without running a Node script, you can impersonate a user inside a transaction block in the Supabase SQL Editor: SQL

begin; set local role authenticated;

  select set_config('request.jwt.claims', '{"sub": ""}', true);

-- Should return only User A's row

  select id from public.profiles;

rollback;

Replace with a real user ID from your Auth dashboard. It’s fast for ad-hoc debugging, though an automated script you run after every migration is what actually prevents regressions.

Run it after every schema change

The real value of this script is running it every time your schema changes. Policies break quietly—a new table, a modified grant, or an AI-generated migration that accidentally drops a constraint. Drop this script into your CI pipeline or pre-deploy checklist, and expand it every time you ship a new table.

Want this architecture ready out of the box?

If you want this exact two-user RLS testing harness pre-baked into a production-ready Express + Supabase starter—complete with authentication, signup triggers, and explicit secure grants for Supabase’s latest defaults—you can grab my starter kit below. 👉

https://shaameer.gumroad.com/l/SupabaseExpressjsProductionBackend/Launch10

(Full disclosure: I'm the author—it's a paid boilerplate designed to save you setup hours, not a formal security audit.)

── more in #developer-tools 4 stories · sorted by recency
── more on @supabase 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-test-supabase…] indexed:0 read:4min 2026-11-15 · —