To test Supabase Row Level Security (RLS) policies, write pgTAP tests that switch the session into the authenticated role, set the request's JWT claims so auth.uid() resolves, then assert which rows each user can read and write. Run them with supabase test db. The trap that makes most RLS tests worthless: the test runner connects as a Postgres superuser, and superusers bypass RLS entirely, so a careless test passes even when the policy is wide open [5].
This guide ships the exact harness (the role switch, the claims, results_eq and throws_ok), the four mistakes that make a green suite lie to you, and the GitHub Actions job that fails the build the moment a migration breaks a policy. If your policy is already deployed and misbehaving and you are diagnosing it by hand, the Supabase RLS debugging walkthrough is the tool for that. This post is about catching the break in CI before it ever ships.
TL;DR:
- Superusers and table owners bypass RLS by default [5].
supabase test dbruns as thepostgressuperuser, so a test that never switches roles proves nothing about your policy. - Become the user first. Switch into the
authenticatedrole and setrequest.jwt.claims(or calltests.authenticate_as()) soauth.uid()returns a real id and the policy actually runs [2][3]. - Assert both directions. Use
results_eqoris_emptyfor what a user can read (theUSINGclause), andthrows_okwith SQLSTATE42501for what they must not write (theWITH CHECKclause) [4][5]. - Run it on every migration. A GitHub Actions job that runs
supabase startthensupabase test dbre-checks every policy on every pull request [1].
Table of contents
- Why do Supabase RLS tests pass when the policy is broken?
- How do you set up pgTAP in a Supabase project?
- How do you impersonate an authenticated user in a pgTAP test?
- How do you assert both read and write isolation?
- What are the four traps that make RLS tests lie?
- How do you run RLS policy tests in CI on every migration?
- Test the policies before you have them
Why do Supabase RLS tests pass when the policy is broken?
Because superusers and table owners bypass Row Level Security by default. The supabase test db runner and the Supabase SQL editor both connect as the postgres superuser, so every row is visible no matter what the policy says. A test that queries the table without changing roles is testing the superuser, not your policy, and it stays green even when the USING expression is empty.
PostgreSQL is explicit about this. "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table" [5]. And the subtler one: "Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY" [5]. In a Supabase project the tables in the public schema are owned by the postgres role, which is exactly the role your test connection uses. Two independent reasons the raw test session ignores your policies.
The roles that RLS does apply to are anon and authenticated, the two ordinary roles PostgREST switches into for every incoming request based on the JWT. Neither is a superuser and neither owns your tables, so the policy runs against them. The entire job of an RLS test is to become one of those roles before you assert anything. This is the same reason the Vitest and Playwright testing guide tells you not to check policies from the SQL editor: the editor lies for the same superuser reason a naive pgTAP test does.
One more behavior worth pinning down, because it shapes what you assert. "If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified" [5]. So a missing or broken policy usually surfaces as an empty result, not an error. Your reads have to assert on row counts, not on the absence of an exception.
How do you set up pgTAP in a Supabase project?
Enable the pgTAP extension, then drive it with the Supabase CLI. Running supabase test new documents_rls.test scaffolds a SQL file in supabase/tests/, and supabase test db runs every file in that folder against your local database with pgTAP as the runner [1]. Each test is plain SQL wrapped in a transaction that rolls back at the end, so a test run never mutates real data.
First, enable the extension. Add this to a migration so every environment (local, CI, staging) has it:
create extension if not exists pgtap with schema extensions;
pgTAP is "a unit testing framework for Postgres" that you write in SQL [1]. A test file follows a fixed shape: declare how many assertions you plan to run, run them, then finish. plan(n) "declares how many tests your script is going to run to protect against premature failure," and finish() reports "any diagnostics about failures or a discrepancy between the planned number of tests and the number actually run" [4].
begin;
select plan(1);
select ok(true, 'pgtap is wired up');
select * from finish();
rollback;
Create it and run it:
supabase test new documents_rls.test
# writes supabase/tests/documents_rls.test.sql
supabase start # boots the local Postgres + services
supabase test db # runs every file in supabase/tests
For RLS work specifically, the Basejump test helpers remove a lot of boilerplate around creating and impersonating users. Install them once via dbdev:
select dbdev.install('basejump-supabase_test_helpers');
create extension "basejump-supabase_test_helpers";
If you have not written the policies yet, generate a first pass with the Supabase RLS policy generator and the guide to writing RLS policies that hold up, then treat everything below as the test that proves the policy actually isolates users.
How do you impersonate an authenticated user in a pgTAP test?
Switch the session into the authenticated role and set request.jwt.claims to a JSON object containing the user's sub (their UUID), so auth.uid() returns that id inside the policy. The Basejump tests.authenticate_as() helper does exactly this, and tests.create_supabase_user() gives you real auth.users rows to authenticate as [2][3].
Start with a table and the two policies you want to prove. A documents table owned per-user is the canonical shape:
create table public.documents (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
title text not null
);
alter table public.documents enable row level security;
create policy "read own documents"
on public.documents for select
using ( (select auth.uid()) = owner_id );
create policy "insert own documents"
on public.documents for insert
with check ( (select auth.uid()) = owner_id );
Under the hood, impersonation is two set local statements. This is the same role switch the manual RLS debugging walkthrough runs interactively in the SQL editor, except here it lives inside an automated assertion that runs on every migration:
set local role authenticated;
set local request.jwt.claims to '{"sub": "00000000-0000-0000-0000-000000000001", "role": "authenticated"}';
The tests.* helpers wrap that pattern so you reference users by a friendly identifier instead of hardcoding UUIDs. tests.create_supabase_user() "creates a new user in the auth.users table," tests.authenticate_as() "authenticates as a user created with tests.create_supabase_user," tests.get_supabase_uid() "returns the user UUID," and tests.clear_authentication() "clears out the authentication and sets role to anon" [3]. Here is the isolation test in full:
begin;
select plan(2);
-- two real auth.users rows
select tests.create_supabase_user('alice');
select tests.create_supabase_user('bob');
-- seed one row for alice while still the superuser test runner
insert into public.documents (owner_id, title)
values (tests.get_supabase_uid('alice'), 'Alice private doc');
-- act as alice: she sees exactly her row
select tests.authenticate_as('alice');
select results_eq(
'select count(*)::int from public.documents',
ARRAY[\[1\]](https://supabase.com/docs/guides/local-development/testing/overview),
'Alice sees her own document'
);
-- act as bob: he sees none of alice''s rows
select tests.authenticate_as('bob');
select is_empty(
'select 1 from public.documents',
'Bob sees none of Alice''s documents'
);
select * from finish();
rollback;
results_eq does "a direct row-by-row comparison of results to ensure that they are exactly what you expect," and is_empty "tests that said query returns no records" [4]. The seed insert happens before the first authenticate_as, on purpose: you want the setup data written as the superuser so RLS never blocks your fixtures, then the role switch so RLS governs the assertions.
How do you assert both read and write isolation?
Test the two halves of a policy separately. A USING clause controls which existing rows a user can see, update, or delete; a WITH CHECK clause controls which new rows they are allowed to create [5]. Assert reads with results_eq or is_empty, and prove a forbidden write is rejected with throws_ok against SQLSTATE 42501 [4].
PostgreSQL keeps the two concerns independent: "Separate expressions may be specified to provide independent control over the rows which are visible and the rows which are allowed to be modified" [5]. That independence is the bug factory. A policy with a correct USING clause and a missing WITH CHECK clause reads perfectly and lets any authenticated user forge a row under someone else's owner_id. A read-only test never catches it. You have to attempt the illegal write and assert it fails:
begin;
select plan(2);
select tests.create_supabase_user('alice');
select tests.create_supabase_user('bob');
select tests.authenticate_as('bob');
-- bob cannot insert a row he does not own (WITH CHECK)
select throws_ok(
format(
'insert into public.documents (owner_id, title) values (%L, %L)',
tests.get_supabase_uid('alice'),
'forged'
),
'42501',
null,
'Bob cannot insert a document owned by Alice'
);
-- anonymous requests see nothing (default-deny + anon role)
select tests.clear_authentication();
select is_empty(
'select 1 from public.documents',
'Anonymous requests see no documents'
);
select * from finish();
rollback;
throws_ok is for exactly this: "when you want to make sure that an exception is thrown by PostgreSQL, use throws_ok() to test for it," and its error-code argument "should be an exception error code (a five-character string)" [4]. A row that violates a WITH CHECK clause raises SQLSTATE 42501, so asserting that code proves the write was blocked by the policy and not by some unrelated constraint. Read tests and write tests are not redundant. They cover the two clauses that fail independently.
What are the four traps that make RLS tests lie?
Four mistakes turn a green suite into false confidence: never leaving the superuser role, switching role but forgetting the JWT claims so auth.uid() is null, not resetting the role between tests, and asserting reads but never writes. Each one produces passing tests against a broken policy, which is worse than no tests at all because it buys trust the policy has not earned.
| Trap | Why the test still passes | Fix |
|---|---|---|
| No role switch | The runner is a superuser, which bypasses RLS, so every row is visible [5] | set local role authenticated (or tests.authenticate_as) before every assertion |
| Role switched, claims missing | auth.uid() returns null, so a USING ((select auth.uid()) = owner_id) clause matches nothing and reads look correctly empty by accident | Set request.jwt.claims with a real sub; assert a positive count for the owner, not just emptiness for others |
| Role never reset | A later assertion runs as the previous user and quietly tests the wrong identity | Wrap each test in begin ... rollback, or call tests.clear_authentication() between users |
| Reads only, no writes | A missing WITH CHECK clause lets forged inserts through, invisible to SELECT assertions | Add a throws_ok insert test per writable policy |
The second trap is the sneakiest, because the test is green for the wrong reason. If you only ever assert that user B sees zero of user A's rows, a policy that returns zero rows for everyone (because auth.uid() is null) passes. Always pair a negative assertion (the other user sees nothing) with a positive one (the owner sees their exact count). One without the other is half a test.
A fifth, rarer trap: if a table uses ALTER TABLE ... FORCE ROW LEVEL SECURITY, even the owner is subject to policies [5], which changes what your fixtures can write. Most Supabase tables do not force it, so seeding as the superuser works, but it is worth knowing why a fixture insert might suddenly start failing.
How do you run RLS policy tests in CI on every migration?
Add a GitHub Actions job that installs the Supabase CLI, runs supabase start to boot the local database, then runs supabase test db. pgTAP returns a non-zero exit code when any assertion fails, so a broken policy fails the build before the migration merges [1]. No production database and no secrets are involved: everything runs against the ephemeral local Postgres the CLI spins up.
name: Database Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
- name: Start Supabase
run: supabase start
- name: Run Tests
run: supabase test db
This is the piece the Vitest and Playwright layers cannot give you. Application tests mock the Supabase client or drive the UI; neither exercises the policy as Postgres evaluates it. The pgTAP job does, and it runs on every pull request that touches supabase/migrations/, which is exactly when a policy regression sneaks in: someone widens a USING clause to fix a bug, ships it, and three tenants can suddenly read each other's rows. A failing check on the PR is the cheapest place to catch that. Fold it into your pre-launch security audit so the policy suite is a release gate, not an afterthought.
Test the policies before you have them
SecureStartKit ships every table with alter table ... enable row level security and no policies at all. Data access runs backend-only through the service_role key, which bypasses RLS by design, so the shipped template has no user-facing policies to regression-test. That is a deliberate posture, not a gap: fewer policies means a smaller attack surface, and the backend-only data access pattern keeps the database off the browser entirely.
The pgTAP net matters the moment you add your first USING clause. As soon as you expose a table to the anon or authenticated client, an untested policy is a cross-tenant leak waiting for the next refactor, and RLS fails silently, so you will not notice until a customer does. Wire the test job in before that first policy, not after. The broader testing setup lives in the Next.js testing guide, and if you would rather start from an architecture that treats deny-all RLS and backend-only access as the default, that is what the SecureStartKit foundation is built to give you.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- Testing Overview, Supabase Docs— supabase.com
- Advanced pgTAP Testing, Supabase Docs— supabase.com
- Test helpers for pgTAP and Supabase, Basejump— github.com
- pgTAP Documentation— pgtap.org
- Row Security Policies, PostgreSQL Documentation— postgresql.org
Related Posts
Supabase RLS Not Working? 7 Symptoms and the Fix [2026]
Supabase RLS returning empty silently? The 7 failure symptoms plus the SQL editor procedure (set role + request.jwt.claims) that finds the cause.
Supabase RLS Policies That Actually Work [2026 Guide]
Most Supabase RLS tutorials stop at 'enable RLS.' Here's how to write policies for ownership, multi-tenant access, admin roles, and fast queries.
Migrate Firebase to Supabase: 3 Security Traps [2026]
Most Firebase to Supabase guides port the data and stop. The 3 security-critical steps: Security Rules to RLS, the cutover window, the old project.