Security

Writing RLS Policy Tests for Every Role and Tenant

Antonio Evans8 min read

A split scene: on the left, a database table drawn as rows of labeled boxes, each tenant a different color, with a policy gate drawn as a turnstile in front of it.
Key points

Key points

  1. 1Write one test per query per role, run it under that role's real grants and policies, and assert the exact set of primary keys against a committed snapshot.
  2. 2Enumerate roles and tenants from the catalog (pg_roles, your tenants table, pg_policies) so the suite grows automatically when a new tenant or role appears.
  3. 3Seed a fixture with at least two tenants whose data overlaps in shape, so a missing predicate returns extra rows instead of the same row count.
  4. 4Fail the build on any snapshot diff and print the ids that leaked or disappeared, because a wrong policy ships as a silent data leak rather than an error.

Why a passing query proves nothing about a policy

A query that returns rows tells you the role has a grant. It does not tell you the policy filtered anything, because the most common failure in row-level security is not an error but a quiet widening: the query succeeds and returns one tenant's rows to another tenant's user. Postgres itself frames this as a default-deny system: once you run ALTER TABLE ... ENABLE ROW LEVEL SECURITY, every normal access must be allowed by a policy, and with no applicable policy you get zero rows rather than an error the PostgreSQL row security documentation. That default-deny behavior is good design and terrible for discovery, because a broken policy and a correct one both return HTTP 200 to your application.

Two checks run before a client touches a table. Grants decide whether a role can run the operation at all, and policies decide which rows the operation applies to; a missing grant raises a 42501 error before any policy runs the Supabase RLS guide. That split matters for your harness: a test that fails with 42501 is telling you about grants, not policies, and you should assert the two separately so a failure names the right layer.

There is a third bypass that tests catch and code review usually misses. Table owners normally bypass row security unless the table is marked FORCE ROW LEVEL SECURITY, and superusers and roles with BYPASSRLS always bypass it PostgreSQL CREATE POLICY. If your test connection is the table owner, every test passes and proves nothing. Run tests as a non-owner role, the same way production does the oneuptime multi-tenant walkthrough.

Enumerate roles and tenants from the catalog, not from a hardcoded list

A hand-maintained list of roles drifts the moment someone adds a service account. Query the catalog instead. The roles you care about are the ones that can reach the table: those holding a grant, plus the special anon-style roles your platform uses. Supabase, for example, maps unauthenticated requests to anon and signed-in requests to authenticated the Supabase RLS guide.

-- roles that hold a privilege on a given table
select distinct grantee
from information_schema.role_table_grants
where table_schema = 'public'
  and table_name = 'invoices'
  and grantee not in ('postgres', 'service_role')
order by grantee;

For tenants, read the tenant table itself. If a tenant exists in production but not in the matrix, you have an untested boundary, and the cheapest fix is generating cases from data rather than from a checklist. The same idea drives generated suites that read policies from the catalog and emit one test per table, command and identity, including owner, another user, another tenant and anon the rlsautotest discussion.

A practical cap: generate a case for every distinct role, and for tenants, take all tenants if you have fewer than roughly a few dozen in the fixture, otherwise a fixed sample of two tenant ids plus any tenant flagged as sensitive. Keep the sample ids in the committed fixture so the matrix is deterministic.

What to enumerateSourceWhy it must come from the catalog
Roles under testinformation_schema.role_table_grantsA hardcoded list misses new service accounts
Tenant idsyour tenants tableAn untested tenant is an untested boundary
Tables with RLSpg_class.relrowsecurityCatches tables where RLS was never enabled
Policies per tablepg_policiesLets you assert each policy has at least one case
Bypass rolespg_roles.rolbypassrlsThose roles must never be used as the test subject

Seed a fixture where a leak is visible in the row set

The fixture is where most RLS suites quietly stop working. If tenant A has zero invoices and tenant B has three, a completely broken policy on tenant A returns nothing and the test passes. The fixture has to be built so that a missing predicate changes the result set.

Three rules make that true:

  1. Every tenant owns at least two rows in every table you test.
  2. Tenant A and tenant B share identical non-key values (same status, same amount, same created date) so the only distinguishing column is the tenant id. That means a missing tenant predicate returns rows that differ only by tenant, which the snapshot diff will show as extra ids.
  3. Include one row that belongs to a third tenant the acting user is a member of, and one that does not. Membership-based policies are where the cross-tenant leaks live, and a two-tenant fixture cannot distinguish "my tenant" from "any tenant I am a member of."

Generated test tooling describes this as reverse-predicate seeding: work backward from each policy to the exact rows that satisfy it and the exact rows that violate it, so "another tenant cannot see this" is asserted against a row that genuinely belongs to another tenant the rlsautotest discussion. You can do the same by hand. For a policy tenant_id = current_setting('app.tenant_id')::uuid, seed rows for tenant A, tenant B, and a user who is a member of both.

Keep the fixture as SQL migrations checked into the repository, applied inside a transaction that rolls back after the run, the pattern the Supabase testing guide uses with begin;, select plan(n); and rollback; the Supabase testing overview. Rolling back keeps the suite idempotent and parallel-safe.

Run each query as the real role and the real tenant

There are two mechanisms in wide use, and the harness should pick one per project rather than mixing them.

Role-based isolation connects (or switches) as a database user whose name or role is the tenant, and the policy compares a column to current_user Craig Kerstiens on RLS for tenants. Session-variable isolation keeps one application role and sets a per-request variable that the policy reads with current_setting the same Crunchy Data walkthrough.

For the second, SET LOCAL scopes the setting to the current transaction so a pooled connection cannot leak one request's tenant into the next the queryplane multi-tenant patterns guide. Your harness should mirror whatever the application does, and use SET LOCAL inside the test transaction so every case starts clean.

begin;
-- the subject of the test: real role, real tenant context
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
set local app.tenant_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';

select id from public.invoices order by id;
rollback;

Two details make this reliable. First, SET LOCAL role only holds until the transaction ends, so wrap every case in its own transaction; a leaked SET ROLE in a long-lived session is exactly the cross-request bleed that SET LOCAL prevents. Second, set both the identity claim and the tenant variable the policy actually reads, even if one appears redundant today. A policy change that switches from one to the other should break the test, not silently keep passing.

If you use pgTAP, the runner drives the same statements inside a transaction, and the Supabase CLI runs the suite with supabase test db the Supabase testing overview. Helper libraries wrap user creation and authentication, for example authenticate_as and clear_authentication the Basejump pgTAP guide. Helpers are convenient, but assert what they set, because a helper that leaves you as postgres turns every test green.

Assert exact ids, and commit them as snapshots

Counting rows is the weakest assertion you can write. Use results_eq comparing the actual id list with a committed expected list. The Supabase example suite uses results_eq to check row counts per user and lives_ok to confirm that an allowed insert succeeds the Supabase testing overview. Counts catch some bugs; exact ids catch reordering, widening and row swaps.

select results_eq(
  $$ select id from public.invoices order by id $$,
  $$ values ('id-101'::uuid), ('id-102'::uuid) $$,
  'tenant A member sees exactly their two invoices'
);

For columns, assert the shape as well as the ids where it matters, because a policy controls rows and does not control which columns a grant exposes. A column-level leak looks like select * returning salary to a role that should only see department, and only a snapshot of the column list catches it. Record the column set once per query per role in the snapshot file.

Store snapshots next to the tests as a single data file keyed by case name, table, role and tenant. The format does not matter as long as regeneration is a deliberate command and a diff is a failure. A compact shape:

case: invoices.list.authenticated.tenant_a
rows: 101, 102
columns: id, amount, status
case: invoices.list.authenticated.tenant_b
rows: 201, 202, 203
columns: id, amount, status

The third tenant in that snapshot, with three rows where the other two have two, is not an accident. It is the row that will appear in tenant A's result if the policy widens, and the diff will say so by name.

Make the snapshot the contract, and regeneration a reviewed change

The point of a committed snapshot is that a policy edit cannot change what a role sees without a human seeing it. When someone edits a policy file, three outcomes are possible and only one is acceptable silently.

OutcomeSnapshot diffWhat it usually meansAction
Same ids, same columnsNoneRefactor, no behavior changeMerge
Fewer ids for a tenantRemoved rowsPolicy narrowed too far, or a join brokeInvestigate before merging
More ids, foreign tenantAdded rowsPolicy widened, or a restrictive policy was droppedBlock the build
New error 42501Grant failureGrant revoked, or role changedFix grants, retest
All cases changedBulk diffSomeone ran regeneration by habitReject and review per case

The last row is the one that erodes the suite. Treat snapshot regeneration as a production change: require a reviewer, and put the case name and the leaked tenant id in the commit message. If regeneration is one keystroke and no one reads the diff, the snapshot becomes a rubber stamp within a month.

For teams that want the matrix generated rather than written, tooling exists that reads policies from the catalog, emits pgTAP tests plus seed data per identity, and prints a per-identity access report with a CI gate that fails on a leak or an unprotected table the rlsautotest discussion. Use it to bootstrap the matrix, then keep the committed snapshots as your contract. Generated cases catch nothing on day one that your snapshot does not catch on day two.

The checklist that keeps the suite honest

Run these checks as part of the suite itself, not as prose in a wiki:

  • Every table in an exposed schema has row security enabled, asserted from pg_class.relrowsecurity.
  • Every policy in that schema appears in at least one test case, joined from pg_policies.
  • No test subject role has rolbypassrls and no test runs as the table owner PostgreSQL row security documentation.
  • Views over protected tables are tested too, because views bypass RLS by default when created by the owner, so a view can hand out rows its policies were meant to withhold the Supabase RLS guide.
  • Storage buckets get the same treatment: uploads to a bucket are denied without a policy on storage.objects, and the service key bypasses RLS entirely, so test with the anon and authenticated keys only the Supabase storage access control guide.
  • Policies that allow writes are tested with WITH CHECK, because a USING-only policy lets a user move a row out of their own visibility, and the mutation needs its own case the queryplane multi-tenant patterns guide.
  • Every case runs inside a transaction that rolls back.

Wrap the checks in one test that fails loudly and names the gap, for example table public.reports has RLS disabled. A missing policy is a default-deny hole in availability and, once someone adds a permissive grant to fix the outage, a data hole.

For performance, do not assert timings in the policy suite; they are unstable in CI. Do assert that the columns your policies filter on are indexed, since a missing index on the tenant column turns every tenant query into a scan, and an index on the policy column has shown over 100x improvement on large tables the Supabase RLS performance discussion.

If your team operates the database through a managed platform and the authorization model is spreading across services, this is a reasonable moment to book a free discovery call and get a recommendation on where RLS should stop and application authorization should begin.

FAQ

FAQ

How many tenants does the test fixture need?

At least two, and for any membership-based policy at least three: a user's own tenant, a tenant the user belongs to, and a tenant the user has no relationship with. Two tenants prove isolation only for the simple case. Three tenants let you distinguish "my tenant" from "a tenant I am a member of" from "a tenant I have no access to," and those middle cases are where cross-tenant leaks usually appear.

Should tests run as the table owner or as the application role?

As the application role, always. Table owners bypass row security by default unless FORCE ROW LEVEL SECURITY is set, and superusers and BYPASSRLS roles always bypass it the PostgreSQL documentation. A suite that connects as the owner or as service_role passes regardless of what the policies say, which is worse than no tests because it produces false confidence.

What is the difference between `42501` and a failing row assertion?

42501 means the role lacks the grant to run the operation, so the check failed before any policy was consulted the Supabase RLS guide. A row assertion failure means the grant was fine and the policy returned the wrong rows. Keeping them separate shortens debugging: a grant error points at GRANT statements, a row diff points at CREATE POLICY and the expressions inside it.

How do I test a policy that depends on `current_setting`?

Set the variable with SET LOCAL inside the test transaction before running the query, using the same setting name the policy reads. SET LOCAL reverts at the end of the transaction, so a pooled connection cannot carry one tenant's context into another request the queryplane guide. Add a case that sets the variable to a tenant the acting user does not belong to and assert zero rows come back.

When should a snapshot change be accepted?

When a reviewer can name the intended behavior change and the diff is limited to the cases that change follows. A diff that adds rows owned by another tenant is a leak until proven otherwise, and a diff that changes every case at once suggests a harness bug or a careless regeneration. Keep the snapshot small per case, so the diff reads like a sentence rather than a wall of ids.

Sources

Sources

  1. Documentation: 18: 5.9. Row Security Policies - PostgreSQL
  2. Row Level Security | Supabase Docs
  3. Documentation: 18: CREATE POLICY - PostgreSQL
  4. How to Secure Multi-Tenant Data with Row-Level Security in PostgreSQL
  5. RLS policies, proven - auto-generated pgTAP tests and ...
  6. Testing Overview | Supabase Docs
  7. Row Level Security for Tenants in Postgres | Crunchy Data Blog
  8. Postgres Row-Level Security: Multi-Tenant Patterns That Hold Up ...
  9. A Guide to testing on Supabase using pgTAP - Basejump
  10. Storage Access Control | Supabase Docs
  11. RLS Performance and Best Practices · supabase · Discussion #14576
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles