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.
9 minute lesson
An RLS test that only checks the happy path proves nothing. The policy’s entire job is denial, so denial is what you test: no session, the owner, another user, malformed input, and a trusted server action.
Build the matrix for the notes table. Two users, Ada and Grace, each owning one note. Five actors, four operations. Every cell holds an expectation: allowed or denied.
Then automate it against a fresh local database, signed in with the publishable key like a real client:
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: '[email protected]',
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, [])
})
Note 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 only asserting on the actual rows reveals that users see nothing, or that a broken policy lets them see everything. Assert on contents, never on the absence of errors.
Writes deny more loudly:
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. Add the anonymous actor too: a client with no session must read zero rows and fail every write.
Keep the privileged path out of user tests
Do not use a service-role client to test ordinary user behavior, because it can bypass RLS — 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 accepting a user-controlled identifier.
Run the suite against a database rebuilt with supabase db reset, so leftover rows from a previous run never mask a hole.
Lesson completed