How to test Postgres row-level security as a specific user

Test PostgreSQL row level security as a real user, not a superuser or service role: SET LOCAL ROLE, request claims and a tenant isolation test matrix.

To test PostgreSQL row level security properly, run your queries the way a real request does: as the application’s database role, with the same user and tenant context your app sets, inside a transaction. Testing as a superuser, the table owner or a service role proves nothing, because those roles skip the policies you’re trying to test.

Row-level security (RLS) is a Postgres feature that limits which rows a role can read or change, using policies attached to a table, as the PostgreSQL row security docs (opens in a new tab) describe. It’s popular in multi-tenant SaaS because a policy keeps protecting tenant data even when application code forgets a WHERE tenant_id = .... That only holds if the policies actually work, and a common testing mistake hides exactly the bugs you care about.

Why do RLS policies pass when you test as an admin?

They pass because admin roles aren’t subject to them. Postgres documents that superusers (opens in a new tab) and roles with the BYPASSRLS attribute always bypass row security. Table owners usually do too, unless the table is set to FORCE ROW LEVEL SECURITY.

That covers the roles most developers reach for first:

Role you test withSubject to RLS?Why
Superuser, such as a local postgresNoSuperusers always bypass row security
Role with BYPASSRLSNoThe attribute skips every policy
Table owner (often the migration role)No, unless FORCE ROW LEVEL SECURITY is setOwners bypass by default
Supabase service_role (secret key)NoIt has the bypassrls attribute
Your app’s runtime role, with user contextYesThis is what you need to test

On Supabase, the Row Level Security guide (opens in a new tab) notes that the postgres role also has bypassrls, and a secret key only bypasses RLS when the request carries no user access token. Views are another trap: they bypass RLS by default unless you create them with security_invoker = true (Postgres 15 and later).

If you need to be sure you’re not being filtered, for example in a backup job, set row_security to off. It doesn’t bypass anything. Instead, Postgres raises an error whenever a policy would have filtered rows.

What does a minimal tenant policy look like?

Here’s an example schema for a tenant-scoped table. It’s a minimal illustration, not a drop-in migration. The application sets app.tenant_id for each request, and the policy compares every row against it:

-- Example only: one tenant-scoped table and an application role
create table projects (
  id        uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  name      text not null
);

alter table projects enable row level security;

create role app_user nologin;
grant select, insert, update, delete on projects to app_user;

create policy projects_tenant_isolation on projects
  to app_user
  using      (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
  with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

USING decides which existing rows are visible or modifiable. WITH CHECK decides which new or updated rows are allowed. When a USING expression is false or null, the row is silently hidden. When a WITH CHECK expression is false or null, the statement fails with an error. According to the CREATE POLICY reference (opens in a new tab), if RLS is enabled and no policy applies, Postgres denies everything by default.

Two details in that policy matter for testing:

How do you test PostgreSQL row level security as a specific user?

Open a transaction, switch to the application role with SET LOCAL ROLE, set the request context transaction-locally, run your checks, then roll back:

-- Example test: act as a user in tenant 1111...
begin;
set local role app_user;
select set_config('app.tenant_id', '11111111-1111-1111-1111-111111111111', true);

select count(*) from projects;              -- only tenant 1111... rows

update projects set name = 'renamed'
where tenant_id = '22222222-2222-2222-2222-222222222222';
                                            -- UPDATE 0: rows are hidden, not an error

insert into projects (tenant_id, name)
values ('22222222-2222-2222-2222-222222222222', 'cross-tenant');
                                            -- ERROR: new row violates row-level security policy

rollback;

After SET ROLE, Postgres checks permissions as though that role had logged in. As the SET ROLE reference (opens in a new tab) explains, your test connection’s role needs the SET option on the target role (granted by membership), unless it’s a superuser. Per the Postgres SET reference (opens in a new tab), SET LOCAL lasts only until the transaction ends, and set_config(..., true) applies only to the current transaction. Rolling back leaves no role, setting or test data behind.

Keep it transaction-local in production code too. Postgres’s set_config documentation (opens in a new tab) notes that a setting made with set_config(..., false) lasts for the rest of the session. With a connection pool, that session gets reused by the next request.

How do you test Supabase row level security as a user?

Supabase works the same way, with its own roles and context. Requests run as anon or authenticated, and the auth.uid() helper returns the requesting user’s ID. Under the hood, the Supabase Auth function definition (opens in a new tab) shows that auth.uid() reads the sub claim from the request.jwt.claims setting, after first checking an older request.jwt.claim.sub setting. Supabase’s REST API (opens in a new tab) is built on PostgREST. PostgREST switches role with SET LOCAL ROLE for each request, per its authentication docs (opens in a new tab), and exposes the JWT claims through current_setting('request.jwt.claims', true), as its transaction settings reference (opens in a new tab) shows.

So you can reproduce a request in SQL. Here’s an example membership-based policy and a test, with made-up IDs:

-- Example policy: members read their tenants' projects
-- (memberships needs its own select policy for this subquery to see rows)
create policy "members read tenant projects" on projects
  for select to authenticated
  using (tenant_id in (
    select tenant_id from memberships where user_id = (select auth.uid())
  ));

-- Example test
begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","role":"authenticated"}', true);
select auth.uid();                -- aaaaaaaa-...
select count(*) from projects;    -- only that member's tenants
rollback;

Supabase’s RLS guide recommends (opens in a new tab) one supabase/tests/<table>_rls.test.sql file per table, asserting allow and deny for each operation and for both anon and authenticated, run with supabase test db. Its testing overview (opens in a new tab) uses pgTAP inside begin and rollback, and its troubleshooting list starts with the two things above: set the role, and set the JWT claims.

One trap the same RLS guide calls out: don’t prove an allowed write with a check that only confirms “no error”. A write blocked by USING matches zero rows without raising anything. Add returning and assert the value came back.

What should a tenant isolation test matrix cover?

Cover each operation for three identities: a user in the right tenant, a user in another tenant, and a request with no context at all. Then add one check that your tests aren’t secretly running as a bypass role.

These are pass conditions to check in your own database, not results we’ve run there.

CaseRole and contextStatementPass condition
Own tenant readApp role, tenant AselectOnly tenant A rows
Own tenant writeApp role, tenant Ainsert ... returningThe row comes back
Other tenant readApp role, tenant Aselect ... where tenant_id = BZero rows
Other tenant update or deleteApp role, tenant Aupdate/delete tenant B rowsZero rows affected; B’s rows unchanged
Cross-tenant insertApp role, tenant Ainsert with tenant BRLS violation error
Move a row outApp role, tenant Aupdate set tenant_id = BRLS violation error
No contextApp role, nothing setselect and insertZero rows; insert fails
No grantA role with no table grant, such as Supabase anon after you revoke its default grantsselectPermission denied (42501)
Bypass sanity checkOwner or migration roleselectSees all rows, proving the other tests didn’t run as this role

Run the “other tenant” cases with real IDs from both tenants, and confirm B’s rows afterwards from a separate, privileged session. A zero-row update only proves isolation if the rows still exist.

How does this help debug a customer permission issue?

It lets you reproduce what one customer’s request can see, rather than what your admin connection can see. When a customer reports missing records, set their role and their tenant or user context in a staging transaction and run the query their endpoint runs. If the rows disappear there, you’ve found the layer that filters them. The wider method for working through role, tenant, flag and entitlement inputs is in debugging customer permission issues.

The same rule applies to support access. If an agent opens a customer’s account through an impersonation session, the database work behind that session should run with the customer’s identity and tenant context, so RLS applies exactly as it does for the customer. A session that quietly uses a service role would show the agent rows the customer can’t see. It would also skip the tenant boundary that RLS is there to protect. Keep the agent’s identity separately for audit, and limit which customers each agent may open, as covered in assigned-customer impersonation.

Devora doesn’t manage database roles or RLS. Its integrations hand your backend verified context that names the customer (the subject) separately from the agent (the actor). Your app maps that subject to its normal user and tenant context, which is what lets your policies apply; Devora’s scope and security guide (opens in a new tab) covers the details. If you want approved, time-limited support sessions that keep the customer’s permissions in place, create a Devora account and try one staging session. Then run the matrix above against it.

Sources

Quick answers

Common questions, answered clearly.

Direct answers to the questions teams usually ask when evaluating this approach.

Does the table owner bypass row level security in Postgres?

Usually, yes. Superusers and roles with BYPASSRLS always bypass policies, and a table's owner bypasses them too unless the table has FORCE ROW LEVEL SECURITY. Run policy tests as the role your application uses, not the role that ran your migrations.

Why does an UPDATE blocked by RLS return 0 rows instead of an error?

A USING expression filters rows. Rows the policy hides are silently left out, so an update or delete against another tenant's rows affects zero rows. A WITH CHECK expression is different: if a new or changed row fails it, Postgres raises an error.

How do I test Supabase row level security as a specific user?

Inside a transaction, switch to the authenticated role with SET LOCAL ROLE, then set the user's JWT claims transaction-locally with set_config('request.jwt.claims', ..., true). auth.uid() reads the sub claim from that setting. Supabase runs these checks as pgTAP tests with supabase test db.

Is SET ROLE enough to test a row-level security policy?

Only if the policy depends on the role alone. Most SaaS policies also read a user or tenant ID from request context, such as current_setting or auth.uid(). Set both the role and that context, or the test runs as a user with no identity and proves very little.

Should support staff bypass RLS to investigate a customer issue?

Not for routine support. A BYPASSRLS or service role sees every tenant's rows, so it can't show you what the customer sees and it widens exposure. Run support queries and sessions with the customer's own role and context so the same policies apply.

Put it into practice

Give your team context.Keep control of access.

Bring approvals, scoped sessions, and access history into your support workflow.