{"slug": "how-to-test-supabase-rls-with-two-users-script-included-by-shaameer", "title": "How to test Supabase RLS with two users (script included) By Shaameer", "summary": "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.", "body_md": "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.\n\nThe 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.\n\nIt works on any table. I’ll use a standard profiles table with owner-only policies as the example.\n\nWhy “it works in my app” proves nothing\n\nWhen 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.\n\nWhile 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.\n\nThe test is simple: spin up two real users, and have one try to touch the other.\n\nWhat the test checks\n\nAssume 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:\n\nconst url = process.env.SUPABASE_URL;\n\nconst anonKey = process.env.SUPABASE_ANON_KEY;\n\nconst serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // Local/CI test only\n\nconst admin = createClient(url, serviceKey, { auth: { persistSession: false } });\n\nasync function makeUser(label) {\n\n  const email = `rls-test-${label}-${Date.now()}@example.com`;\n\n  const password = 'Test-password-123!';\n\nconst { data, error } = await admin.auth.admin.createUser({\n\n    email,\n\n    password,\n\n    email_confirm: true,\n\n  });\n\n  if (error) throw error;\n\nconst client = createClient(url, anonKey, { auth: { persistSession: false } });\n\n  const { error: signInError } = await client.auth.signInWithPassword({ email, password });\n\n  if (signInError) throw signInError;\n\nreturn { id: data.user.id, client };\n\n}\n\nlet failed = 0;\n\nfunction check(name, ok) {\n\n  console.log(ok ? `PASS  ${name}` : `FAIL  ${name}`);\n\n  if (!ok) failed++;\n\n}\n\nconst a = await makeUser('a');\n\nconst b = await makeUser('b');\n\ntry {\n\n  // 1. A can read their own row\n\n  const own = await a.client.from('profiles').select('*').eq('id', a.id);\n\n  check('A can read own profile', !own.error && own.data.length === 1);\n\n// 2. A cannot read B's row\n\n  const other = await a.client.from('profiles').select('*').eq('id', b.id);\n\n  check('A cannot read B', !other.error && other.data.length === 0);\n\n// 3. A cannot update B's row\n\n  const upd = await a.client\n\n    .from('profiles')\n\n    .update({ full_name: 'hacked' })\n\n    .eq('id', b.id)\n\n    .select();\n\n  const bRow = await admin.from('profiles').select('full_name').eq('id', b.id).single();\n\n  check(\n\n    'A cannot update B',\n\n    (upd.data ?? []).length === 0 && bRow.data?.full_name !== 'hacked'\n\n  );\n\n// 4. Logged-out client cannot read the table\n\n  const anon = createClient(url, anonKey, { auth: { persistSession: false } });\n\n  const anonRead = await anon.from('profiles').select('*');\n\n  check('anon cannot read profiles', !!anonRead.error || (anonRead.data ?? []).length === 0);\n\n} finally {\n\n  // Cleanup: deleting the auth user also removes the profile via ON DELETE CASCADE\n\n  await admin.auth.admin.deleteUser(a.id);\n\n  await admin.auth.admin.deleteUser(b.id);\n\n}\n\nconsole.log(failed === 0 ? '\\nAll checks passed' : `\\n${failed} check(s) failed`);\n\nprocess.exit(failed === 0 ? 0 : 1);\n\nAdd your keys to a local .env file and run:\n\nBash\n\nnode --env-file=.env test-rls.mjs\n\nReading the results\n\n• 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;.\n\n• 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.\n\n• 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.\n\nPro 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.\n\nA quick alternative in the SQL editor\n\nIf 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:\n\nSQL\n\nbegin;\n\n  set local role authenticated;\n\n  select set_config('request.jwt.claims', '{\"sub\": \"\"}', true);\n\n-- Should return only User A's row\n\n  select id from public.profiles;\n\nrollback;\n\nReplace  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.\n\nRun it after every schema change\n\nThe 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.\n\nWant this architecture ready out of the box?\n\nIf 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.\n\n👉 \n\n[https://shaameer.gumroad.com/l/SupabaseExpressjsProductionBackend/Launch10](https://shaameer.gumroad.com/l/SupabaseExpressjsProductionBackend/Launch10) \n\n(Full disclosure: I'm the author—it's a paid boilerplate designed to save you setup hours, not a formal security audit.)", "url": "https://wpnews.pro/news/how-to-test-supabase-rls-with-two-users-script-included-by-shaameer", "canonical_source": "https://dev.to/syed_shaameerali_52c39bf/how-to-test-supabase-rls-with-two-users-script-includedby-shaameer-312o", "published_at": "2026-11-15 08:17:03+00:00", "updated_at": "2026-11-15 08:20:46.448208+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Supabase", "Shaameer"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-test-supabase-rls-with-two-users-script-included-by-shaameer", "markdown": "https://wpnews.pro/news/how-to-test-supabase-rls-with-two-users-script-included-by-shaameer.md", "text": "https://wpnews.pro/news/how-to-test-supabase-rls-with-two-users-script-included-by-shaameer.txt", "jsonld": "https://wpnews.pro/news/how-to-test-supabase-rls-with-two-users-script-included-by-shaameer.jsonld"}}