DEV Community

Cover image for Your Supabase Auth Is Only as Good as Your RLS Policies. So Test Them.
Sarah
Sarah

Posted on

Your Supabase Auth Is Only as Good as Your RLS Policies. So Test Them.

TL;DR

  • With Supabase, auth answers "who are you?" and RLS policies answer "what can you touch?" The second question is where real data leaks happen.
  • RLS failures are silent. A blocked select returns [], not an error, so manually clicking around the app won't catch a broken policy.
  • The fix: integration tests that sign up two real users and check that each one can't read, edit, or delete the other's rows.
  • You can run those tests against a local Supabase-compatible backend in seconds, including in CI.

The bug nobody notices

Here's a situation I've seen more than once. Sign-up works. Login works. The session persists. You ship.

Then someone opens DevTools, takes the anon key from your bundle (it's meant to be public), and runs:

await supabase.from('notes').select('*')
Enter fullscreen mode Exit fullscreen mode

They get back every note from every user, because RLS was never enabled on that table.

The anon key is public by design. Your RLS policies are the only thing between that key and your entire database. Auth by itself only tells Postgres who the user is. Whether that user is allowed to touch a row is decided entirely by the policies you wrote, and if you didn't write them correctly, nothing else stops the request.

The schema we'll protect

A simple notes table where each user should see only their own rows:

-- supabase/migrations/20261007000000_notes.sql
create table public.notes (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null default auth.uid() references auth.users (id) on delete cascade,
  body       text not null,
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

create policy "read own notes"
  on public.notes for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "insert own notes"
  on public.notes for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

create policy "update own notes"
  on public.notes for update
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "delete own notes"
  on public.notes for delete
  to authenticated
  using ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

Two details worth pointing out:

  • (select auth.uid()) instead of a bare auth.uid() lets Postgres evaluate the function once per query instead of once per row. On large tables this makes a real performance difference.
  • default auth.uid() on user_id means clients don't need to send it at all. That also gives an attacker one less field to tamper with.

Four ways RLS quietly breaks

These are the mistakes your tests need to catch:

1. RLS never enabled. You created the policies but forgot enable row level security. The policies exist but aren't enforced, so the table is fully open.

2. An insert policy that checks the wrong thing.

-- ❌ any logged-in user can create rows owned by anyone
create policy "insert notes" on public.notes for insert
  to authenticated
  with check (true);
Enter fullscreen mode Exit fullscreen mode

This lets user B insert rows with user_id set to user A's ID. That allows spoofed content and, depending on your app, possibly worse.

3. Policies that trust user_metadata. auth.jwt() -> 'user_metadata' can be edited by the user through supabase.auth.updateUser(). If a policy checks user_metadata ->> 'role' = 'admin', any user can make themselves an admin. Store roles in app_metadata or in your own table instead.

4. Views that skip RLS. By default, a view runs with its owner's permissions, which means the underlying table's RLS doesn't apply. On Postgres 15+, create views with with (security_invoker = true).

None of these mistakes produce an error. Your app keeps working normally while the data is exposed.

The tests

The approach is to create two real users with two separate clients, then check that each one is properly isolated from the other. I'm using Vitest, but any test runner works.

npm i -D vitest @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode
// tests/rls.test.ts
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { beforeAll, describe, expect, it } from 'vitest'

const URL = process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321'
const ANON_KEY = process.env.SUPABASE_ANON_KEY!

// Each user gets an isolated client. No shared session storage.
function newClient() {
  return createClient(URL, ANON_KEY, {
    auth: { persistSession: false, autoRefreshToken: false },
  })
}

async function signUpUser() {
  const client = newClient()
  const email = `rls-${crypto.randomUUID()}@example.com`
  const { data, error } = await client.auth.signUp({
    email,
    password: 'correct-horse-battery-staple',
  })
  if (error) throw error
  if (!data.session) {
    throw new Error('No session returned. Disable email confirmation for local tests.')
  }
  return { client, id: data.user!.id }
}

describe('notes RLS', () => {
  let alice: { client: SupabaseClient; id: string }
  let bob: { client: SupabaseClient; id: string }
  let aliceNoteId: string
  let bobNoteId: string

  beforeAll(async () => {
    alice = await signUpUser()
    bob = await signUpUser()

    const a = await alice.client.from('notes').insert({ body: 'alice secret' }).select().single()
    if (a.error) throw a.error
    aliceNoteId = a.data.id

    const b = await bob.client.from('notes').insert({ body: 'bob note' }).select().single()
    if (b.error) throw b.error
    bobNoteId = b.data.id
  })

  it("bob can't read alice's notes", async () => {
    const { data, error } = await bob.client.from('notes').select('*').eq('id', aliceNoteId)
    expect(error).toBeNull() // RLS filters rows. It doesn't throw.
    expect(data).toEqual([])
  })

  it("bob can't update alice's notes", async () => {
    const { data } = await bob.client
      .from('notes')
      .update({ body: 'pwned' })
      .eq('id', aliceNoteId)
      .select()
    expect(data).toEqual([]) // zero rows matched

    const { data: check } = await alice.client
      .from('notes')
      .select('body')
      .eq('id', aliceNoteId)
      .single()
    expect(check!.body).toBe('alice secret')
  })

  it("bob can't delete alice's notes", async () => {
    await bob.client.from('notes').delete().eq('id', aliceNoteId)

    const { data } = await alice.client.from('notes').select('id').eq('id', aliceNoteId)
    expect(data).toHaveLength(1)
  })

  it("bob can't create rows owned by alice", async () => {
    const { error } = await bob.client
      .from('notes')
      .insert({ body: 'spoofed', user_id: alice.id })
    expect(error?.code).toBe('42501') // new row violates row-level security policy
  })

  it("bob can't hand his note over to alice", async () => {
    const { error } = await bob.client
      .from('notes')
      .update({ user_id: alice.id })
      .eq('id', bobNoteId)
    expect(error?.code).toBe('42501')
  })

  it('anonymous users see nothing', async () => {
    const { data } = await newClient().from('notes').select('*')
    expect(data).toEqual([])
  })

  it('alice can still read her own note', async () => {
    const { data } = await alice.client.from('notes').select('body').eq('id', aliceNoteId)
    expect(data).toEqual([{ body: 'alice secret' }])
  })
})
Enter fullscreen mode Exit fullscreen mode

The last test matters more than it looks. A policy that blocks everyone will pass every "can't" test. Always include at least one positive check that the owner still has access.

Try this as an experiment: comment out enable row level security in the migration and run the tests again. Most of them should fail. That confirms the tests are actually checking something.

Running it locally (and in CI)

These tests need a real auth server and real Postgres. A mock won't work, because RLS runs inside Postgres itself.

The standard approach is supabase start, which runs the full local stack in Docker. It works, but it starts about a dozen containers, which is a lot for a test suite and noticeably slow in CI.

Lately I've been using tinbase for this. It's an open-source, Supabase-compatible backend that runs as a single process without Docker. It runs real Postgres with RLS and GoTrue-style auth, reads your existing supabase/migrations/*.sql, and the official supabase-js connects to it without changes. Since everything in this post is just migrations plus supabase-js, nothing in the test file needs to change.

npx tinbase start
# copy the anon key it prints, then:
SUPABASE_ANON_KEY=<key> npx vitest run
Enter fullscreen mode Exit fullscreen mode

It's still alpha, so I use it for local development and CI, not production. But for answering "do my policies actually hold?" in a couple of seconds, it's a good fit.

A minimal GitHub Actions job:

# .github/workflows/rls.yml
name: RLS tests
on: [push, pull_request]

jobs:
  rls:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx tinbase start &
      - run: npx wait-on tcp:127.0.0.1:54321
      - run: npx vitest run
        env:
          SUPABASE_URL: http://127.0.0.1:54321
          SUPABASE_ANON_KEY: ${{ secrets.LOCAL_ANON_KEY }}
Enter fullscreen mode Exit fullscreen mode

The rule I follow now

Every table with RLS gets two tests before it ships: one showing a stranger can't access the data, and one showing the owner can. It takes about ten minutes per table, and it catches exactly the bugs that manual testing misses.

What's the worst RLS mistake you've found in your own app, or in someone else's? Drop it in the comments. I'd like to collect the strange edge cases.

Top comments (0)