Testing Supabase RLS locally when it depends on JWT custom claims
If your Row-Level Security depends on custom JWT claims, say an org_id for multi-tenancy or a role claim for RBAC, you've probably hit this: it works perfectly on Supabase Cloud, and then your local tests come back with empty tables or "new row violates row-level security policy." The policy is fine. The problem is how the claims reach auth.jwt() locally.
Why it works on Cloud but not in local Studio
On a real request, the chain is: GoTrue mints a JWT (running your custom access-token hook, so the custom claims are in it) → PostgREST receives that JWT and puts the decoded payload into a Postgres setting called request.jwt.claims → your policy calls auth.jwt(), which simply reads that setting. So the custom claims are present, and RLS behaves.
The local Studio Dashboard breaks that chain. When you impersonate a user in the local dashboard, it isn't tightly integrated with GoTrue the way Cloud is. It gives you a session, but the JWT it puts in front of your query doesn't carry your custom claims. So auth.jwt() returns a payload with the custom claims empty, every policy that reads them evaluates false, and you get an empty result (or a denied insert). It looks like your RLS is broken; really, the test harness just isn't feeding it the claims your app feeds it.
The fix: test at the database level
The key insight is that auth.jwt() is not magic; it just reads the request.jwt.claims setting. So you can reproduce a real request exactly by setting that yourself, in a transaction, as the right role. No GoTrue, no PostgREST, no Dashboard involved:
begin;
-- become the role your app uses, and inject the claims GoTrue would have minted
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"00000000-0000-0000-0000-0000000000aa",
"role":"authenticated",
"app_metadata":{"org_id":"org-a","role":"admin"}}', true);
-- auth.jwt()->'app_metadata'->>'org_id' now returns 'org-a' inside your policy
select * from documents; -- runs under RLS as this exact identity
rollback;
Now the policy sees the same claims it sees in production, and you can assert what this identity should and shouldn't be able to do. Copy the exact JSON shape from a real API session's token (app_metadata vs user_metadata vs a top-level claim) so it matches what your hook actually emits.
Multi-tenancy: test a real rival tenant
For tenant isolation, the meaningful negative test isn't an outsider with no org; it's a legitimate user of a different org. Run the same block again with org-b in the claims and assert they see none of org A's rows. That's the test that catches a subtly-wrong predicate like org_id IS NOT NULL, which a no-org outsider would sail right through but a real second tenant would expose.
Three gotchas that cause exactly this
set_config(..., true)is transaction-local. It only lasts for the current transaction, so theset_configand the query have to be in the same transaction. Run them separately and the claim is already gone.- You must
set role. If you stay connected as the table owner or a superuser, RLS is bypassed entirely, so everything passes and proves nothing. Becomeauthenticatedoranon. - The JSON shape must match. If your policy reads
auth.jwt()->'app_metadata'->>'org_id', the claims you set need that nesting exactly.
The bonus: this runs in CI
The Dashboard will never run in your pipeline, but this does. Wrap the same pattern in pgTAP and you have RLS tests, including the custom-claim and multi-tenant cases, that run on every push, the same way the app actually executes them.
However you get there, the principle is the same: test RLS by setting the claims yourself at the database level, as the role your app uses. That's the only place the policy runs the way production runs it.
