Auth and Row Level Security

Test the denied paths

Run authorization tests as anonymous, authenticated, cross-user, and privileged actors instead of checking only the happy path.

An RLS test that only checks the happy path proves nothing. The whole job of a policy is denial, so denial is what you test. Five actors: no session, the owner, another user, malformed input, and a trusted server action.

Build the matrix for the notes table first, on paper. Two users, Ada and Grace, each owning one note. Five actors, four operations. Every cell holds one expectation: allowed or denied. Twenty cells, and most of them say denied.

Then automate it against a fresh local database. Sign in with the publishable key, like a real client would:

import { createClient } from '@supabase/supabase-js'
import test from 'node:test'
import assert from 'node:assert/strict'

const ada = createClient(url, publishableKey)
await ada.auth.signInWithPassword({
  email: 'ada@example.com',
  password: 'correct-horse-battery-staple',
})

test('ada cannot read grace notes', async () => {
  const { data, error } = await ada.from('notes')
    .select()
    .eq('user_id', graceId)

  assert.equal(error, null)
  assert.deepEqual(data, [])
})

Look at the shape of that denial. A blocked select does not raise an error. RLS filters the rows out and you get data: [], exactly like an empty table.

This is the failure mode that fools people. The app “works”, every query succeeds, and nobody notices that users see nothing. Or worse, a broken policy lets them see everything, and every query still succeeds. Only asserting on the actual rows reveals either problem. Assert on contents, never on the absence of errors.

Writes fail loudly

Writes are different. Postgres rejects them with a real error:

const { error } = await ada.from('notes')
  .insert({ user_id: graceId, title: 'spoofed' })

console.log(error.code, error.message)
// 42501 new row violates row-level security policy for table "notes"

That is the with check clause rejecting an ownership spoof. Error code 42501 is Postgres saying “insufficient privilege”. Assert on that code in your tests, so a policy that silently starts accepting the row fails the suite.

Add the anonymous actor too. A client with no session must read zero rows and fail every write. That test takes four lines and catches the day someone drops a policy by mistake.

Keep the privileged path out of user tests

Don’t use a secret-key client to test ordinary user behavior. It bypasses RLS, so it passes every check and validates nothing. Test privileged server actions separately, as their own actor with their own expectations. Keep those privileged jobs narrow, and make each one perform its own authorization before it accepts a user-controlled identifier.

Run the suite against a database rebuilt with supabase db reset. Leftover rows from a previous run can mask a hole, and a clean database has no leftovers.

My advice is to wire this suite into CI and run it on every migration. Policies are code. They deserve the same tests.

Lesson completed