
Row-Level Security Test Pattern for Postgres: Catch Leaks Pre-Prod
An engineering pattern for proving Postgres RLS policies actually isolate tenants — in CI, on every pull request, before the leak ships.
The problem
The bug nobody wants to find in production is the cross-tenant data leak. Customer A logs in, hits an endpoint, and sees a row that belongs to customer B. The fix is a one-line policy change. The cleanup is a disclosure email, a regulatory filing if you are in a regulated vertical, and a trust hit that takes quarters to recover from. Postgres row-level security is the standard answer for preventing this — but RLS only works if the policies are actually correct, and the only way to know they are correct is to test them.
Service-role tests prove nothing
The trap most teams fall into is testing RLS with the service role key. The tests pass, the dashboard is green, and the bug ships anyway. The Supabase docs are explicit that the service role bypasses RLS entirely, so any query run with that key returns rows regardless of whether your policies are correct, wrong, or missing. A green test under the service role tells you the SQL parses and the data exists. It tells you nothing about whether tenant A can read tenant B's row.
Single-tenant fixture passes a USING (true) policy
The second trap is the single-tenant fixture. You seed one tenant, you sign in as that tenant's user, you query, you assert the right rows come back. The test passes. It also passes when the policy is USING (true) — because with one tenant in the table, every row is the tenant's row. The bug surfaces the moment a second tenant exists, which is the moment a real customer ends up seeing another real customer's data.
New table without a policy ships wide open
The third trap is the "new table without a policy" failure mode. Postgres applies its default-deny rule only after RLS is enabled on the table. The Postgres docs state that when row security is enabled and no policy exists, no rows are visible or modifiable — but that protection kicks in only after ALTER TABLE ... ENABLE ROW LEVEL SECURITY. A new migration that creates a table and grants select to authenticated users, without the ENABLE line, is a wide-open table. Code review catches some of these. The ones it misses ship.
The business consequence is the same as any data isolation bug. Either a customer sees something they should not (incident, disclosure, churn) or your security review reveals the gap before launch (delay, remediation cost, lost deal). The pattern below catches both failure modes in CI, on the pull request that introduced them.

What changes for your business
The architecture has three pieces. A deterministic two-tenant fixture, a test runner that switches Postgres role per assertion, and a schema-level invariant check that runs against pg_catalog. They live alongside the application code in version control, run via supabase test db and a Node test runner, and execute on every pull request.
Deterministic two-tenant fixture
Start with the fixture. Two tenants, each with a user, each with seeded rows in every table the application protects. The fixture is loaded inside a transaction that gets rolled back after the test, so each run starts from the same known state.
-- supabase/tests/fixtures/two_tenants.sql
-- Loaded at the top of every RLS test file.
insert into auth.users (id, email, raw_user_meta_data)
values
('11111111-1111-1111-1111-111111111111', 'a@test.local', '{"tenant_id":"aaaa1111-aaaa-1111-aaaa-111111111111"}'),
('22222222-2222-2222-2222-222222222222', 'b@test.local', '{"tenant_id":"bbbb2222-bbbb-2222-bbbb-222222222222"}');
insert into public.tenants (id, name) values
('aaaa1111-aaaa-1111-aaaa-111111111111', 'Tenant A'),
('bbbb2222-bbbb-2222-bbbb-222222222222', 'Tenant B');
insert into public.projects (id, tenant_id, name) values
('a1a1a1a1-0000-0000-0000-000000000001', 'aaaa1111-aaaa-1111-aaaa-111111111111', 'Project A1'),
('a1a1a1a1-0000-0000-0000-000000000002', 'aaaa1111-aaaa-1111-aaaa-111111111111', 'Project A2'),
('b2b2b2b2-0000-0000-0000-000000000001', 'bbbb2222-bbbb-2222-bbbb-222222222222', 'Project B1');
Two tenants is the minimum that lets you assert isolation. Tenant A has multiple rows so the "I see my own rows" direction is non-trivial; tenant B's row exists so the "I cannot see the other tenant's row" direction has a target to fail on. The UUIDs are fixed values so assertions can reference them by literal.
Per-assertion role switching in pgTAP
The test runner switches the authenticated session role per assertion. In pgTAP this is a set_config('request.jwt.claims', ...) plus set local role authenticated inside the transaction. The role switch is the whole point — without it, the test runs as the superuser that loaded the fixtures, which the Postgres docs note bypasses RLS entirely. Skip the role switch and you are back to writing service-role tests that prove nothing.
-- supabase/tests/rls/projects_isolation.sql
begin;
select plan(4);
\i fixtures/two_tenants.sql
-- Helper that sets the authenticated session to a given user id.
create or replace function test_authenticate_as(user_id uuid) returns void as $
begin
perform set_config(
'request.jwt.claims',
json_build_object(
'sub', user_id::text,
'role', 'authenticated'
)::text,
true
);
set local role authenticated;
end;
$ language plpgsql;
-- Assertion 1: tenant A can see tenant A's projects.
select test_authenticate_as('11111111-1111-1111-1111-111111111111');
select results_eq(
'select id from public.projects order by id',
$values ('a1a1a1a1-0000-0000-0000-000000000001'::uuid),
('a1a1a1a1-0000-0000-0000-000000000002'::uuid)$,
'tenant A sees only tenant A projects'
);
-- Assertion 2: tenant A cannot see tenant B's projects.
select is_empty(
$select id from public.projects
where tenant_id = 'bbbb2222-bbbb-2222-bbbb-222222222222'$,
'tenant A cannot read tenant B rows'
);
-- Assertion 3: tenant A cannot UPDATE tenant B's row.
select is(
(with attempt as (
update public.projects set name = 'pwned'
where id = 'b2b2b2b2-0000-0000-0000-000000000001'
returning 1
) select count(*) from attempt),
0::bigint,
'tenant A cannot update tenant B rows'
);
-- Assertion 4: tenant A cannot DELETE tenant B's row.
select is(
(with attempt as (
delete from public.projects
where id = 'b2b2b2b2-0000-0000-0000-000000000001'
returning 1
) select count(*) from attempt),
0::bigint,
'tenant A cannot delete tenant B rows'
);
select * from finish();
rollback;
The shape of every assertion is the same: switch into a tenant's role, run the operation that should be blocked, assert the result set is empty or the row count is zero. The transaction-and-rollback wrapper at the top and bottom means the next test starts from the same fixture state — no test order coupling, no leftover data.
Schema-level invariant check against pg_catalog
The schema-level invariant check runs as its own pgTAP test and catches the "new table without a policy" failure mode by querying pg_catalog directly. The Postgres documentation is explicit that the default-deny rule applies only when RLS has been enabled — so the invariant has two parts: every application-schema table must have rowsecurity = true, and every such table must have at least one policy.
-- supabase/tests/rls/schema_invariants.sql
begin;
select plan(2);
-- Every application-schema table has RLS enabled.
select is(
(select count(*)::bigint
from pg_tables
where schemaname = 'public'
and rowsecurity = false
and tablename not in ('schema_migrations')), -- allowlist for known-safe tables
0::bigint,
'every public table has RLS enabled'
);
-- Every application-schema table has at least one policy.
select is(
(select count(*)::bigint
from pg_tables t
where t.schemaname = 'public'
and t.tablename not in ('schema_migrations')
and not exists (
select 1 from pg_policies p
where p.schemaname = t.schemaname
and p.tablename = t.tablename
)),
0::bigint,
'every public table has at least one policy defined'
);
select * from finish();
rollback;
That second assertion is the one that catches the most production bugs. RLS-enabled with no policy is the default-deny state — safe but unusable. RLS-disabled with no policy is the wide-open state — dangerous and the most common new-table mistake. The pg_catalog query distinguishes the two and fails the build on the wrong one.
Vitest layer covering the JWT roundtrip
The TypeScript layer covers what pgTAP cannot: the end-to-end check that the auth layer issues the right JWT, the client library passes it through, and the policy fires the same way it does in production. Using vitest and the Supabase JS client, this looks roughly like:
// tests/rls/projects.test.ts
import { createClient } from "@supabase/supabase-js";
import { beforeAll, describe, expect, it } from "vitest";
const url = process.env.SUPABASE_URL!;
const anonKey = process.env.SUPABASE_ANON_KEY!;
async function signedInClient(email: string, password: string) {
const client = createClient(url, anonKey);
const { data, error } = await client.auth.signInWithPassword({ email, password });
if (error) throw error;
return client;
}
describe("projects RLS — tenant isolation", () => {
let tenantA: Awaited<ReturnType<typeof signedInClient>>;
let tenantB: Awaited<ReturnType<typeof signedInClient>>;
beforeAll(async () => {
tenantA = await signedInClient("a@test.local", "test-password");
tenantB = await signedInClient("b@test.local", "test-password");
});
it("tenant A sees only tenant A projects", async () => {
const { data, error } = await tenantA.from("projects").select("id");
expect(error).toBeNull();
expect(data?.map((r) => r.id).sort()).toEqual([
"a1a1a1a1-0000-0000-0000-000000000001",
"a1a1a1a1-0000-0000-0000-000000000002",
]);
});
it("tenant A cannot read tenant B rows by id", async () => {
const { data, error } = await tenantA
.from("projects")
.select("id")
.eq("id", "b2b2b2b2-0000-0000-0000-000000000001");
expect(error).toBeNull();
expect(data).toEqual([]); // RLS hides the row; no error, just empty
});
it("tenant A cannot update tenant B rows", async () => {
const { data, error } = await tenantA
.from("projects")
.update({ name: "pwned" })
.eq("id", "b2b2b2b2-0000-0000-0000-000000000001")
.select();
expect(error).toBeNull(); // RLS returns success-with-zero-rows, not an error
expect(data).toEqual([]);
});
});
A subtle point in that third test: under RLS, a write that touches no rows returns success with an empty data set rather than a permissions error. A test that asserts error is non-null will fail even though the policy is doing its job. Assert against the returned row set, not the error shape.
CI gate that blocks merge
The CI integration is one job that runs supabase test db followed by npx vitest run tests/rls. The pgTAP suite runs first because it is faster and catches the schema invariants in seconds; the vitest suite runs second because it is slower but covers the JWT roundtrip. Both fail the pull request build on a single assertion failure. Gating merge on the build means a policy regression cannot land without an explicit human override.

Common failure modes
The first sharp edge is the SECURITY DEFINER bypass that nobody tested. The Postgres docs note that security-definer functions can access data not available to the calling user — which is the whole reason they exist. The bug is when a definer function returns rows the caller is not supposed to see because the function's internal query has no tenant filter. The fix is one test per definer function that calls it as each tenant's role and asserts the result is scoped correctly. Without that test, the function is a hole punched through every RLS policy that protects the tables it reads.
The second is the BYPASSRLS role attribute. Postgres documents that superusers and roles with BYPASSRLS bypass row security entirely. A test that connects as the role the migrations run under will pass any RLS assertion because the row security system is not consulted. The test runner's role switch — set local role authenticated — is what avoids this; if the switch is missing or the role lacks the correct grants, the test silently runs as the migration superuser and reports success on a broken policy. Add an explicit assertion at the top of every test file that the current role is authenticated, not the migration role.
The third is the policy-on-wrong-command bug. A policy defined for select does not apply to inserts or updates or deletes. A table with a select policy and no others lets any authenticated user delete any row, because the default-deny applies per command. The test pattern above covers select, update, and delete explicitly for this reason. If your tests only assert read isolation, you have not tested write isolation, and the bug is one missing policy clause away.
The fourth is the JWT claim name drift. The auth layer issues a JWT with tenant_id in the metadata; the policy reads auth.jwt() -> 'app_metadata' ->> 'tenant_id'. A refactor renames the claim. The TypeScript tests sign in fine, the queries return data, and now every tenant sees every other tenant's rows because the policy comparison is null = null. The pgTAP suite catches this because the SQL-level role switch sets the claim by literal — but only if the policy assertion is written against the literal-set claim. The fix is to also write a single integration test that signs in via the real auth flow and asserts the JWT contains the expected claim path before the RLS assertions run.
The fifth is the migrations-without-rollback test. The schema invariant check above passes when run against the current schema. It does not catch a migration that adds a new table and forgets the policy, because the test runs after all migrations are applied — by which time the table exists with whatever protection (or lack of it) the migration left it in. The check still catches the bug, but the failure message is unhelpful. Augment it by running the suite once per pending migration in CI: apply the migration, run the invariants, roll back, move to the next. A failure now points at the specific migration that introduced the regression.
The sixth is the schema-search-path trick. RLS applies per table; the search path determines which schema's table you hit. A policy on public.projects does not protect private.projects, and a careless application that lets the search path get reordered can hit the unprotected one. The invariant check above scopes to schemaname = 'public'. If your application uses multiple schemas, expand the check — and assert in code that the search path is the expected value at request time.
What this looks like in production
At BFEAI the RLS test suite is one of the gates on the protected main branch. The pgTAP file runs against a supabase test db instance spun up by the GitHub Actions runner; the vitest file runs against the same instance after the pgTAP pass. The whole suite finishes in under 90 seconds on a cold runner, which is fast enough to be a per-PR gate rather than a nightly job. A failing assertion blocks the merge, and the failure message points at the specific tenant, table, and operation that leaked.
The schema invariant check is the one I would land first if I were starting from a codebase with no RLS tests at all. It is short, the failure mode it catches is the highest-frequency one, and it requires no per-table assertion code — a new table inherits the check the moment it appears in pg_tables. Most teams I work with go from "we trust RLS" to "we know RLS is correct" the first time this check fails on a pull request that nobody had flagged in review.
The per-policy assertion files are added incrementally, one per table that holds sensitive data. The pattern in the code above scales: copy the file, replace the table name and the tenant-scoped IDs, run it. Resist the urge to write a single mega-test that covers every table — when one assertion fails, you want the file name to tell you what is broken, not require a scroll through 400 lines of output to find the failing assertion among the passing ones.
The dashboard a CTO actually wants from this is two numbers: tables in the application schema with RLS disabled (target: zero) and tables with RLS enabled but no policy (target: zero except for explicit allowlist entries). Both come from the same pg_catalog query the invariant check uses. Surface them in the same place you surface other security posture metrics — alongside dependency vulnerabilities and secrets-scan results — and the team builds a habit of treating an RLS gap the same way they treat a critical CVE.
The runbook for "the RLS suite failed on a PR" is short by design. Step one: read the assertion message, which names the table and the operation. Step two: open the policy file for that table and reproduce the failing query locally as the failing tenant. Step three: fix the policy or fix the test fixture — usually the policy. Step four: rerun the suite. The whole loop is minutes, which is the point. Catching the bug at PR time, with the author still in context, is what makes the cost of fixing it small.
The other operational detail worth calling out is how to handle SECURITY DEFINER functions over time. Each one needs its own test, and each one is a place where a future refactor can quietly widen the bypass. The pattern I use is to keep a manifest of definer functions in the test suite — a SQL query against pg_proc for prosecdef = true — and assert that the count matches a checked-in expected list. If someone adds a new definer function, the count changes, the test fails, and the new function gets reviewed before it merges. It is the same shape as the schema invariant: use the catalog to detect the change, fail the build, force the conversation.
What to watch in your own implementation
Open supabase/tests (or wherever your pgTAP files live) and grep for service_role. If the test runner is connecting with the service role key at any point in the RLS-specific tests, those tests are not proving what their names claim. Move them to the authenticated role with an explicit set local role authenticated, and rerun the suite. Some tests will start failing; those are the policies that were broken the whole time and the suite was not catching it.
Next, count the tables in your public schema and count the test files in your RLS directory. If the second number is smaller than the first by more than a handful, you have tables without per-policy coverage. Start with the tables that hold the most sensitive data — billing rows, PII, anything subject to regulatory disclosure — and write the four-assertion file from the pattern above for each one. The marginal cost per file is small once the first one exists.
Finally, run the schema invariant query against your production database (read-only, off-hours):
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname not in ('pg_catalog', 'information_schema', 'auth', 'storage')
order by rowsecurity, schemaname, tablename;
select t.schemaname, t.tablename
from pg_tables t
where t.schemaname = 'public'
and not exists (
select 1 from pg_policies p
where p.schemaname = t.schemaname and p.tablename = t.tablename
);
Any row in the second result set is a table that either has no RLS at all or has RLS enabled without a policy — and the difference is the difference between a wide-open table and a default-deny one. That number is the one to take to your team. The pattern above is how it gets to zero and how it stays there.
Outcomes you should expect
What this delivers
- Cross-tenant data leaks are caught by CI on the pull request that introduced them, instead of by a customer in production.
- A new table added to the schema cannot ship without an RLS policy, because the pg_catalog check fails the build until one exists.
- RLS regressions surface in seconds during local development, not weeks later during a security review or a SOC 2 audit.
- Engineers stop reasoning about whether the service_role key was used in a test — the test runner enforces the authenticated role on every assertion.
Primary sources
By the numbers
When row security is enabled on a table, if no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified.
Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table.
Policy expressions are run as part of the query and with the privileges of the user running the query, although security-definer functions can be used to access data not available to the calling user.
The Supabase CLI provides Postgres testing using the supabase test db command, powered by the pgTAP extension.
Supabase provides special Service keys, which can be used to bypass RLS, so tests run with the service role do not exercise policies at all.
pgTAP ships RLS-specific assertion functions including policies_are, policy_roles_are, and policy_cmd_is, in addition to results_eq for asserting that a policy returns the correct data.
Live in production today
The same engineering, shipped in production at BFEAI.
I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.
7
Production apps
200K+
Keywords generated
1,500+
AI scans run
7,000+
Sites automated
Common questions
What buyers ask before reaching out
Why don't service_role tests prove anything about RLS?
Supabase documents that the service role key bypasses RLS entirely, so any query you run with it returns rows whether your policies are correct or completely missing. A passing test under service_role tells you the data exists and the query compiles — it tells you nothing about whether tenant A can see tenant B's row. The whole point of an RLS test is to assert what an authenticated user can and cannot see, which means the test session has to be running as that authenticated role.
What's the minimum fixture I need to test RLS properly?
Two tenants, each with seeded data in every table you want to protect, and one user per tenant whose JWT or local role the test runner can switch into. With one tenant you can only test 'I can see my own row', which is the easy direction. With two tenants you can test 'I cannot see the other tenant's row', which is the direction that catches actual policy bugs.
Should the tests run in CI on every pull request?
Yes. RLS bugs are introduced when someone adds a new table, modifies a policy, or refactors auth helpers — those are exactly the changes a pull request gates. Running the suite per PR catches the regression at the moment it is introduced, when the author still has context. Running it nightly instead means you catch the leak hours or days later, after it has merged and possibly already shipped.
How do you detect a new table that was added without any RLS policy?
Query pg_catalog directly. The pg_tables view shows rowsecurity to indicate whether RLS is enabled on a table, and pg_policies lists the policies defined. A test that joins those two and asserts every application-schema table has rowsecurity = true and at least one policy entry catches the 'forgot the policy' failure mode automatically. Add it to the suite once and you stop relying on code review to notice.
How do you test SECURITY DEFINER functions that bypass RLS on purpose?
Treat them as their own attack surface. Write tests that confirm the function returns only what the caller is authorized to see — not what the function owner can see — by calling the function as each tenant's role and asserting the result set. The Postgres docs note that security-definer functions can be used to access data not available to the calling user, which is exactly why each one needs an explicit test that the bypass is scoped correctly.
Does pgTAP do everything, or do you still need a TypeScript test runner?
pgTAP is the right tool for assertions that live inside the database — policy existence, policy commands, results_eq for 'tenant A sees this set'. A TypeScript runner like vitest is the right tool for assertions that go through the application's auth layer — sign in as user A via the client, run the query, assert the response. Most teams I work with use both: pgTAP for the schema-level invariants, vitest for the end-to-end isolation check.
What's the most common RLS bug these tests catch?
The 'new table without a policy' bug, by a wide margin. Someone adds a feature, creates a table, writes the migration, forgets to enable RLS or forgets to add a tenant_id check. The default-deny rule only applies once RLS is enabled — without ALTER TABLE … ENABLE ROW LEVEL SECURITY, the table is wide open to anyone with grant. The pg_catalog check from the suite catches it before the migration lands.
Can these tests run against the production database?
They run against a freshly-seeded copy of the schema, not production. The fixture is deterministic — two tenants, fixed UUIDs, known data — which is the opposite of production. The supabase test db command spins up a local Postgres instance for exactly this reason. Pointing the suite at production would either pollute it with fixture data or return non-deterministic results that make failures unactionable.
Ready to see if this is a fit?
A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.