Test RLS with set local role and request.jwt.claims in a transaction

Simulate a Supabase request without minting JWTs; require one positive and one negative test per tenant table, and force RLS so the owner can't bypass it.

RLS & Auth

· Chapter

4

·

3

min read

The answer. You don't need real JWTs to test row-level security. Policies see two things: the current role and the request.jwt.claims setting. Set both yourself inside a transaction: set local role authenticated; set local request.jwt.claims = '{...}'. set local scopes both to the transaction, so the role flip and the fake claims roll back automatically, even when an assertion fails. Then make it a rule: every PR touching a multi-tenant table ships RLS enabled and forced, policies per verb (or a written decision to omit one), one positive test (the active-org user can) and one negative test (another org's user cannot). Otherwise CI fails.

The pattern.

// rls.test.ts — postgres.js + vitest
async function asUser<T>(sql: postgres.Sql, u: { userId: string; orgId: string },
  fn: (tx: postgres.TransactionSql) => Promise<T>): Promise<T> {
  return sql.begin(async (tx) => {
    const claims = { sub: u.userId, role: "authenticated",
      app_metadata: { active_org_id: u.orgId, active_org_role: "owner" } };
    await tx.unsafe(`set local role authenticated`);
    await tx.unsafe(`set local request.jwt.claims = '${JSON.stringify(claims)}'`);
    return fn(tx);
  }) as Promise<T>;
}

it("A cannot see B's rows", async () => {
  const rows = await asUser(sql, { userId: userA, orgId: orgA }, (tx) => tx`select id from public.my_table`);
  expect(rows.map((r) => r.id)).not.toContain(rowOfOrgB);
});
it("A cannot update B's row: zero rows, not an error", async () => {
  const rows = await asUser(sql, { userId: userA, orgId: orgA },
    (tx) => tx`update public.my_table set name = 'x' where id = ${rowOfOrgB} returning id`);
  expect(rows.length).toBe(0);
});
alter table public.my_table enable row level security;
alter table public.my_table force row level security;  -- owner can't bypass either

The receipt. In our product (HarperFlow), the rule and the idiom have been in force since 2026-05-19 (runbook and first test file, same day). As of 2026-08-26 the migrations directory forces RLS in 36 migration files (65 force row level security statements), and the organizations-isolation suite runs four tests against a live database — including one that demotes a user to editor in the membership table and asserts that a stale JWT still claiming owner cannot update the org: the policy reads live membership, not the claim. A negative UPDATE test isn't an exception; it's returning id coming back empty. Assert on zero rows, not a throw.

Watch out.

  • Without force, the table owner (postgres) bypasses RLS, so anything running as owner leaks across tenants silently. enable alone isn't the test you think it is.
  • using filters reads, updates and deletes; with check validates the new row. Write policies need both, or an insert can land in another tenant.
  • Read claims with current_setting('request.jwt.claims', true); the true returns null instead of erroring when a migration runs with no JWT context.

Related: custom-access-token-hook-is-instance-config · tracked-migration-ledger-checksum-same-transaction