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.enablealone isn't the test you think it is. usingfilters reads, updates and deletes;with checkvalidates the new row. Write policies need both, or an insert can land in another tenant.- Read claims with
current_setting('request.jwt.claims', true); thetruereturns 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