Home / RLS / Test tenant isolation

Supabase RLS · Testing

How to test tenant isolation in Supabase

supabase-js · any test runner✓ code tested against a real database
TL;DR

Sign in as two real users, have B create a row, then assert A reads 0 rows for it and can't insert a row for B. Run it against a real Postgres in CI — a policy you didn't test is a policy you don't have.

01 The only proof that holds

You can't eyeball a policy and know it's correct. The proof is behavioral: sign in as tenant A, and confirm A can reach A's rows and cannot reach or write B's. Do it through the client, as authenticated — the SQL editor bypasses RLS, so testing there proves nothing.

02 The test (two signed-in clients)

rls.test.ts

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

const signIn = async (email) => {
  const c = createClient(URL, ANON_KEY)
  await c.auth.signInWithPassword({ email, password: 'test-pw' })
  return c
}

test('A cannot read B rows', async () => {
  const a = await signIn('a@test.dev')
  const b = await signIn('b@test.dev')

  const { data: row } = await b.from('notes')
    .insert({ title: 'B secret' }).select().single()

  const { data } = await a.from('notes').select().eq('id', row.id)
  expect(data).toHaveLength(0)          // blocked, not just absent
})

test('A cannot insert for B', async () => {
  const a = await signIn('a@test.dev')
  const { error } = await a.from('notes')
    .insert({ title: 'x', user_id: B_ID })
  expect(error).toBeTruthy()                // new row violates RLS
})

03 What to assert

A reads A's rows
A gets 0 rows for B (blocked, not just filtered by a WHERE)
A cannot INSERT a row for B (no WITH CHECK hole)
anon (no session) reads nothing

04 Run it in CI

Point the test at a real Supabase/Postgres (a CI branch database or a local supabase start) and run it on every migration. A new table that ships without a correct policy should turn the build red. To catch the coarser failure — a table with RLS off entirely — add an RLS gate in CI.

◆ FREE · MIT · npm + GitHub Action

Catch this before it ships

airlock-rls is a CI gate that fails your build when a table ships exposed or a policy is permissive — the same class of bug, caught on the pull request instead of in prod.

npx airlock-rls

Or start from nextjs-supabase-starter — auth + a table with RLS + an isolation test, so a fresh table is safe by default.

FREE PDF

Grab the Supabase RLS cheat sheet

The golden rules, the footguns that leak in prod, correct policy snippets, and the isolation test — on one page.

FAQ

Should I use pgTAP instead of a JS test?

pgTAP works and runs in the database, but a JS integration test with two real signed-in clients tests the exact path your app uses — auth, the anon key, and the policies together. That end-to-end realism is worth a lot; use whichever your team will actually keep green.

Why assert zero rows instead of an error on read?

RLS filters reads silently — a blocked SELECT returns an empty set, not an error. So the correct assertion for reads is length 0. Writes, by contrast, raise the row-level-security violation you can assert on directly.