Testing Supabase RLS locally when it depends on JWT custom claims

By Munaf Khatri · June 22, 2026

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

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.

Writing one of these by hand per table, per command, per identity is a lot of careful SQL. rlsautotest generates it for you: it reads your policies, sets the claims per identity and tenant, seeds matching data, and asserts both directions, entirely at the DB level so it's immune to the Studio/GoTrue gap. Free and open source.

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.