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 (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The "RLS failures are silent" point cuts both ways, and the second direction is the one I see most often when auditing: a permissive policy is just as silent as a missing one. select * returns rows, no error, nothing in logs — the only difference is the rows are the leak.

One gap worth adding to the two-user test: it never exercises the anon role. The pattern I find most in public repos isn't "RLS off" — it's RLS enabled with a policy that quietly grants anon full reads. Either explicitly (to anon using (true)) or with no TO clause at all, which Postgres evaluates for public — anon included. Two authenticated test users can pass their isolation checks perfectly while the table is world-readable through the anon key that ships in the bundle.

So the matrix has three roles, not two:

anon          -> always [] or an error, never rows
user A vs B   -> cross-reads blocked
user A        -> own rows readable/writable
Enter fullscreen mode Exit fullscreen mode

And a catalog query that catches the permissive-policy class regardless of tests — useful as a pre-flight check in CI:

select tablename, policyname, roles, qual
from pg_policies
where roles::text in ('{anon}','{public}') and qual = 'true';
Enter fullscreen mode Exit fullscreen mode

Empty result is the baseline. In the audits I run, the recurring shape is the "login helper" policy — written so the sign-up screen can check if an email exists, accidentally exposing every column of the table (three real cases written up here: dev.to/cekuu35/3-supabase-rls-leak...).

One heads-up for anyone running this against a local backend in CI: Supabase's October 30 default-privileges change means new tables stop auto-granting access to anon/authenticated. If any test implicitly relied on those defaults, it'll surface around then — loudly, which is the useful direction. Worth adding the explicit grants to the migration files now so the suite stays green through the change.

Collapse
 
vladtkdev profile image
Vlad Tkachenko •

For the edge-case collection: a security definer function in public. It's callable through /rest/v1/rpc/ with the anon key and runs as its owner, and a function created in the dashboard or a migration is owned by postgres, which bypasses RLS. So every test here can pass while bob calls an RPC that returns alice's notes. Supabase's functions guide says any role can call a function by default, anon included. I'd add one test per RPC that calls it as bob, or revoke execute from public and anon and grant it back where it's needed.

Smaller thing: the migration has no grants. Tables created after 30 October don't get the automatic ones any more (new projects since May already work that way), so on those every request answers 42501 permission denied instead of [], and the suite goes red before it gets to the policies.

Thank for the post!