You already fixed the sequential scan. Your RLS policy filters with an indexed tenant_id IN (...) check instead of scanning the whole table, and query times dropped back to milliseconds. Then a few weeks later, the same query starts creeping up again as your user_roles table grows.
That's because your policy is still joining against it on every row to check permissions, and no amount of indexing on tenant_id fixes a join problem. Custom JWT claims solve this differently: instead of looking up a user's role in a table on every row, you embed the role and tenant ID directly into the JWT at sign-in, so Postgres reads it straight out of the token with zero joins.
This guide covers how to set up Supabase's custom access token hook correctly, how to write RLS policies that read those claims efficiently, and four production gotchas most tutorials skip entirely: stale claims after a permission change, users who belong to more than one organization, JWT size limits, and a missing WITH CHECK clause that quietly lets tenants leak into each other's data.
custom_access_token_hook. Read the claim with a cached (SELECT (auth.jwt() ->> 'tid')::uuid) subquery instead of a join, and plan for claim invalidation early, since the token's default 3600-second lifetime means a permission change doesn't take effect until the session refreshes.
Table of Contents
- Why Does Joining a Roles Table Kill RLS Performance?
- How Supabase's Custom Access Token Hook Works
- Writing RLS Policies That Read Claims Instead of Joining
- What Happens to Claims When a User's Role Changes Mid-Session?
- How Do You Handle Users With Multiple Organizations?
- Quick Answers About Custom JWT Claims for RLS
- Token Size Limits: How Big Is Too Big for a JWT?
- The WITH CHECK Clause Most Tutorials Forget
- Frequently Asked Questions
Why Does Joining a Roles Table Kill RLS Performance?
Every RLS policy that checks something like EXISTS (SELECT 1 FROM user_roles WHERE user_id = auth.uid() AND role = 'admin') forces Postgres to run that subquery for every row the outer query touches, even on a table where tenant_id is already indexed and filtered correctly.
Here's the thing: an indexed tenant_id IN (...) filter and a role-lookup join solve two different problems. The first narrows which rows are even candidates. The second still has to touch a separate table, and often a separate index, for every one of those candidate rows, on every request your API serves.
At a few hundred rows per query that join barely registers. At the volumes a growing multi-tenant SaaS product hits, particularly once a permissions table has millions of rows across thousands of tenants, that repeated lookup becomes the new bottleneck, and it's one your tenant_id index was never going to fix.
A role check that requires a join for every row will always cost more than one that's already sitting in the request, and that's exactly what a JWT claim gives you.
How Supabase's Custom Access Token Hook Works
Simply put, the custom access token hook is a Postgres function Supabase's auth server calls every time it mints a new access token, letting you inject extra claims into the JWT before it's signed and handed to the client. Supabase documents the mechanics in its Custom Claims & RBAC guide, and it's worth understanding before you write a single policy.
Start with a table that tracks which tenant a user belongs to and what role they hold there. A B2B SaaS user usually belongs to more than one organization, so this needs to be a one-to-many relationship from the start, not a single role column bolted onto auth.users.
-- One row per (user, tenant) membership. is_active marks which
-- membership is currently driving the session's claims.
create type public.member_role as enum ('owner', 'admin', 'member');
create table public.memberships (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
tenant_id uuid references public.tenants on delete cascade not null,
role public.member_role not null,
is_active boolean not null default false,
unique (user_id, tenant_id)
);
Now write the hook function itself. It reads the active membership for the signed-in user and writes tid (tenant ID) and role onto the token's claims.
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
active_tenant_id uuid;
active_role public.member_role;
begin
select tenant_id, role
into active_tenant_id, active_role
from public.memberships
where user_id = (event->>'user_id')::uuid
and is_active = true
limit 1;
claims := coalesce(event->'claims', '{}'::jsonb);
if active_tenant_id is not null then
claims := jsonb_set(claims, '{tid}', to_jsonb(active_tenant_id));
claims := jsonb_set(claims, '{role}', to_jsonb(active_role));
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
That coalesce(event->'claims', '{}'::jsonb) matters more than it looks. The hook fires on sign-up too, sometimes before a trigger has finished creating the user's first membership row. Skip the null handling and you'll see the hook throw invalid input syntax for type json and fail the entire sign-in, not just the claims.
Lock the function and the table down so only Supabase's auth service can read them. This isn't optional: if authenticated users can execute this function directly or query memberships outside the hook's context, you've opened a way to forge claims.
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
grant select on table public.memberships to supabase_auth_admin;
revoke all on table public.memberships from authenticated, anon, public;
create policy "auth admin can read memberships"
on public.memberships
as permissive for select
to supabase_auth_admin
using (true);
Finally, register the hook from Authentication > Hooks in the dashboard, or through config.toml if you manage the project with the Supabase CLI. Nothing fires until this step is done, even if the function itself is deployed correctly.
Once the hook is registered, every new or refreshed token carries the tenant and role your policies need, with no separate query required to fetch them.
The three-value member_role enum here works well for most teams. If you need finer granularity, Owner, Admin, Manager, and Member with per-action permissions like documents:write instead of one flat role check, see our guide to hierarchical Postgres RBAC for B2B SaaS, which builds a normalized roles and permissions schema behind the same claims-based approach.
Writing RLS Policies That Read Claims Instead of Joining
With the claims in place, a policy becomes a straight comparison against the token instead of a subquery against another table.
alter table public.documents enable row level security;
alter table public.documents force row level security;
create policy "tenant read isolation"
on public.documents
for select
to authenticated
using (
tenant_id = (select (auth.jwt() ->> 'tid')::uuid)
);
Notice the (select ...) wrapper around auth.jwt(). That's not stylistic. Postgres's planner treats a wrapped scalar subquery as invariant across the rows in a single statement and caches it once, as an InitPlan, instead of re-evaluating the JWT parse on every row. Without the (select ...) wrap, Postgres executes auth.jwt() on every single scanned row. As detailed in Supabase's official RLS Performance and Best Practices guide, wrapping function calls or role lookups in a subquery is the single most important rule for achieving single-execution caching per statement.
Compare that to what the join-based version had to do for the same check: open a roles or permissions table, look up the row for the current user, and repeat that lookup for every candidate row in the outer query. The claims-based version does none of that. The tenant ID is already sitting in the request; Postgres just has to read it.
A claims-based policy replaces a per-row table lookup with a per-statement token read, and that's the entire performance story in one sentence.
What Happens to Claims When a User's Role Changes Mid-Session?
Nothing, automatically. A JWT is signed and self-contained, so once it's issued its claims stay frozen until the token expires or the session refreshes, even if you change that user's role in the database a second later.
Supabase's default access token lifetime is 3600 seconds. That means a user you just removed from a tenant, or downgraded from admin to member, keeps their old claims and old access for up to an hour unless you actively force a token refresh or add revocation checks. While querying a permissions table directly provides immediate consistency (at the cost of extra query overhead), JWT claims prioritize zero-join read throughput with eventual consistency. For standard apps, an hourly refresh cycle is standard; for SOC2/GDPR compliance where revocation must be instantaneous, pairing claims with our invalidation timestamp pattern below gives you the best of both worlds.
The fix isn't just shortening every token's lifetime, although that helps. It's pairing the claim with a cheap, indexed check that catches tokens issued before a permission change.
-- One row per user. Bump the timestamp whenever that user's
-- role or tenant access changes, and any token issued before
-- it becomes stale even though it hasn't technically expired.
create table public.claim_invalidations (
user_id uuid primary key references auth.users on delete cascade,
invalid_before timestamptz not null default now()
);
create or replace function public.claims_are_current()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select to_timestamp((auth.jwt() ->> 'iat')::bigint)
>= coalesce(
(select invalid_before
from public.claim_invalidations
where user_id = (select auth.uid())),
to_timestamp(0)
);
$$;
iat is the standard JWT claim for when the token was issued. Comparing it against a single indexed row per user, instead of the full membership table, gives you near-immediate revocation without bringing back the join you just removed. Add it to every policy alongside the tenant check.
create policy "tenant read isolation (revocable)"
on public.documents
for select
to authenticated
using (
tenant_id = (select (auth.jwt() ->> 'tid')::uuid)
and (select public.claims_are_current())
);
When you change someone's role or remove them from a tenant, update their row in claim_invalidations to now() in the same transaction. Everything they try to do with the old token fails immediately, and their next refreshSession() call picks up the new claims cleanly.
A claim that can be silently invalidated by timestamp is what turns "eventually correct" into "correct on the next request."
How Do You Handle Users With Multiple Organizations?
You pick one organization as the session's active tenant and re-mint the token when the user switches, rather than trying to fit every organization a user belongs to into a single JWT.
This is exactly what the is_active flag on the memberships table is for. Switching organizations in the UI should call a small API endpoint that flips is_active to the target membership and clears it on the others, then calls supabase.auth.refreshSession() from the client. That re-runs the hook and returns a token scoped to the newly active tenant, with no full logout required.
-- Called by the "switch organization" endpoint, inside a transaction
update public.memberships set is_active = false
where user_id = (select auth.uid());
update public.memberships set is_active = true
where user_id = (select auth.uid())
and tenant_id = $1;
There's a second pattern worth knowing for apps where a user's org count stays genuinely small, under about ten. Instead of an active-tenant flag, embed a compact map of every org the user belongs to directly in the claims, keyed by tenant ID.
-- claims -> {"orgs": {"<tenant_id>": "admin", "<tenant_id_2>": "member"}}
create policy "tenant read isolation (multi-org map)"
on public.documents
for select
to authenticated
using (
((select auth.jwt()) -> 'orgs') ? tenant_id::text
);
That ? operator is a JSONB existence check. It's a clean one-liner, and it avoids the extra round trip of a session refresh on every org switch. It doesn't scale past a handful of organizations, though, because every org you add makes the token, and therefore every cookie and request header carrying it, bigger. Past roughly ten organizations per user, go back to the active-context pattern.
Pick the active-context pattern by default, and only reach for the compact map when you're certain org counts per user will stay small.
Quick Answers About Custom JWT Claims for RLS
What Causes RLS Joins to Slow Down at Scale?
Simply put, an RLS policy that checks a role or permission by querying another table has to repeat that query for every row the outer query touches. A small user_roles table hides this cost. A memberships table with millions of rows across thousands of tenants does not, and the join shows up as the dominant cost in EXPLAIN ANALYZE long after tenant filtering itself is fully indexed.
Join-Based vs Claims-Based Permission Checks at a Glance
| Aspect | Details |
|---|---|
| Symptom | Query times climb again as the roles or memberships table grows, even after tenant_id filtering is indexed |
| Root Cause | The RLS policy joins to a roles table on every row instead of reading pre-embedded claims |
| Fix | Add tenant_id and role as custom claims via custom_access_token_hook, read with a cached auth.jwt() subquery |
| Trade-off | Claims go stale until the session refreshes, so a claim-invalidation check is needed for fast revocation |
| Applies To | Supabase Postgres, and any Postgres plus PostgREST setup that can inject JWT claims |
When Does This Apply?
This matters once your permissions table is large enough to show up as a real cost in EXPLAIN ANALYZE, or once request volume is high enough that a few extra milliseconds per query compound into a real number. For a small app with a handful of tenants and a tiny roles table, the join is cheap and this migration can wait.
Pros and Cons of Claims-Based RLS
- Pro: Removes the per-row join entirely, replacing it with a single cached token read
- Pro: Works the same way on plain PostgREST, not just Supabase
- Pro: Keeps sensitive role data out of client-writable tables
- Con: Claims are stale by default until the session refreshes or you add an invalidation check
- Con: Adds one more piece of infrastructure, the hook function, that needs its own testing and monitoring
- Con: Token size grows with every claim you add, which matters more than it looks like it should
Token Size Limits: How Big Is Too Big for a JWT?
A JWT carrying too many claims can silently break cookie storage or trip a reverse proxy's header size limit, well before Postgres ever sees the request.
Two limits matter in practice. Browsers cap individual cookies at 4096 bytes under RFC 6265, which bites hard if you're using a server-side rendering framework that persists the session in a cookie. Most reverse proxies and edge gateways, Cloudflare and Nginx included, also cap combined request header size around 8KB by default. A bloated token can push a request over that line and produce a 431 Request Header Fields Too Large or a 502 Bad Gateway that has nothing to do with your application code.
Two habits keep tokens lean. First, strip the default metadata Supabase attaches automatically, like user_metadata and OAuth provider payloads, before you add your own claims.
claims := (event->'claims') - 'user_metadata' - 'app_metadata';
claims := jsonb_set(claims, '{tid}', to_jsonb(active_tenant_id));
claims := jsonb_set(claims, '{role}', to_jsonb(active_role));
Second, keep what you add small. A UUID and a short role string are cheap. A JSON array listing every permission a role has is not. If you find yourself needing that, encode permissions as an integer bitmask instead: 7 is a lot smaller than ["read","write","admin"] on every single request.
The moment claims start being generous, the token stops being free, and something downstream, a cookie, a header limit, or your own JSONB parsing cost, ends up paying for it.
The WITH CHECK Clause Most Tutorials Forget
Simply put, a policy with only a USING clause checks the row before you touch it, but never checks what the row looks like after your update, which means a user can quietly reassign one of their own rows to a different tenant.
-- Vulnerable: only validates the row before the update
create policy "tenant update (incomplete)"
on public.documents
for update
to authenticated
using (
tenant_id = (select (auth.jwt() ->> 'tid')::uuid)
);
Under that policy alone, an authenticated user can run an update that sets tenant_id to someone else's tenant on a row they legitimately own, and Postgres will allow it. USING passed before the write happened. Nothing checked the row afterward.
PostgreSQL's own CREATE POLICY reference is explicit about this split: existing rows are checked against USING, and rows being written are checked separately against WITH CHECK. Skip the second clause and you've only secured half the operation.
-- Fixed: also validates the row after the update
create policy "tenant update (safe)"
on public.documents
for update
to authenticated
using (
tenant_id = (select (auth.jwt() ->> 'tid')::uuid)
)
with check (
tenant_id = (select (auth.jwt() ->> 'tid')::uuid)
);
This is the same class of mistake covered in our roundup of silent RLS mistakes, and it's worth checking every write policy on every table you convert to claims-based checks, not just the new ones. An old join-based policy that happened to work by accident, because of how the join filtered things, can fail silently once you swap in a direct claim comparison.
Every policy that allows INSERT or UPDATE needs its own WITH CHECK, and "it worked in testing" isn't proof that it does.
Frequently Asked Questions
How do I add custom claims to a Supabase JWT?
Define a function matching the custom_access_token_hook(event jsonb) signature, then enable it from Authentication > Hooks in the dashboard. Grant execute only to supabase_auth_admin, never to authenticated or anon.
Can I update a user's claims without forcing them to log out?
Yes. Call supabase.auth.refreshSession() from the client. It re-runs the hook for the current user and issues a new access token with fresh claims, no full re-login required.
Does auth.jwt() work outside Supabase, on plain PostgREST?
Yes. PostgREST populates request.jwt.claims from any JWT it verifies. You can read the same data with current_setting('request.jwt.claims', true)::jsonb ->> 'claim', even without Supabase's auth.jwt() wrapper.
Why does auth.jwt() return null when I test policies in the SQL editor?
Studio's impersonation sets the Postgres role but doesn't always populate the JWT claims setting. Test inside a transaction instead: manually set request.jwt.claims to a JSON string, then roll back.
What's the difference between raw_app_meta_data and raw_user_meta_data?
raw_user_meta_data can be written by the client, so it shouldn't be trusted for authorization. raw_app_meta_data is backend-only, which is why it, or a custom claims hook, is the safe source for role and tenant data.
Do I still need indexes if I switch to claims-based policies?
Yes. You're removing the join to a roles table, not the need to filter by tenant_id. Keep an index on tenant_id and any other filtered column, or you'll trade one slow scan for another. Our full RLS query optimization guide covers how composite indexing, application filters, and a lean policy fit together.
Will the client see my custom claims in the current user object?
Not automatically. The hook modifies the signed token, not the stored auth.users row, so client SDK user objects won't reflect it. Decode the access token itself with a JWT decode utility to read hook-added claims on the frontend.
Is a global role claim enough, or do I need a tenant_id claim too?
For a multi-tenant product, you need both. A role alone can't stop a user from reading another tenant's rows. Pair the role claim with a tenant_id claim and check both in every policy.
Custom JWT claims turn a per-row join into a single cached token read, and that's the real difference between an RLS policy that keeps scaling and one that quietly gets slower every time your memberships table grows. Set up the custom_access_token_hook with tenant ID and role, read claims through a wrapped (select auth.jwt() ->> ...) subquery so Postgres caches it as an InitPlan, and don't stop there. Handle claim staleness after a permission change, keep the token small, support users who belong to more than one organization, and pair every USING with a matching WITH CHECK. Skip any one of those and you've traded a performance problem for a security one.
If you're still deciding how tenants and roles should be modeled in your schema in the first place, our complete guide to multi-tenant database design in Supabase covers the foundation this pattern builds on. Bookmark this one too. You'll want the stale-claims fix and the WITH CHECK example again the first time a support ticket mentions a user seeing data that isn't theirs.