Custom JWT claims with Supabase auth hooks (and the two traps)
If you check a user's permissions on every API call, you're doing auth at the wrong layer. Inject the claim into the JWT at login with a Supabase auth hook, so the token carries it. Here's the pattern, plus the two traps that make it fail silently.
tsukumo
Short version: if your API looks up a user's role or permissions on every request, you are enforcing auth at the wrong layer. With Supabase you can put the permission inside the token: an auth hook, which is a Postgres function GoTrue runs while minting the JWT, injects a custom claim at login. From then on the permission travels in the token, verified once per request from its signature, usable even inside row-level security. The pattern itself is small. What makes it fail in production are two traps: the signing secret and the function grant. This is exactly the kind of thing we wired recently, so here is the honest version.
Why a claim in the token beats a check in your API#
Because the token is verified once and trusted everywhere; an API check is paid again on every endpoint.
When a permission lives in the JWT, every request carries it, and verifying it is just checking the signature you already check. No extra lookup, no per-endpoint logic, and it works in places your API code never runs, most importantly inside Postgres row-level security policies that fire before your handler. When the permission lives in your API instead, you pay for the lookup on every call, you duplicate the rule across endpoints, and your database-level rules are left blind.
The principle: decide the permission once, at the moment of login, and let the signed token carry it. Re-deriving identity on every request is the same anti-pattern as re-reading state you could have held.
The custom-access-token hook is a Postgres function GoTrue calls while building the JWT. It receives the token's claims, you add yours, you return them.
```sql -- runs while GoTrue mints the access token create function public.add_permission_claim(event jsonb) returns jsonb language plpgsql as $$ declare claims jsonb; perm text; begin select role into perm from public.user_roles where user_id = (event->>'user_id')::uuid;
The function reads your own table (user_roles) and writes an app_permission claim onto the token. No API change, no client change. The permission is decided at login from data you control, and it is now part of every token that user presents.
Every service that mints or verifies the token has to agree on one signing secret.
If one part of your stack validates JWTs with a different secret than GoTrue signed them with, you get the worst kind of bug: tokens that look completely valid and are rejected anyway, or verification that fails in one service and passes in another. There is no useful error pointing at the cause. The fix is dull and absolute: the JWT signing secret is one value, shared by everything that touches the token, and rotated everywhere at once.
The hook only works if the auth admin role can run it and the function can read what it needs.
GoTrue executes the hook as supabase_auth_admin. That role has to be granted execute on your function, and the function has to be able to read the tables it consults. Miss the grant and the hook quietly fails during token minting: users get tokens without your claim, and every downstream permission check is now deciding on a claim that was never set. It fails silently, which is the dangerous way for an auth control to fail.
```sql grant execute on function public.add_permission_claim to supabase_auth_admin; grant select on public.user_roles to supabase_auth_admin; ```
| Inject a JWT claim at login | Check permissions in the API | |-----------------------------|------------------------------| | Verified from signature, no lookup | A database lookup per request | | Works inside row-level security | Invisible to database rules | | Decided once, at login | Re-derived on every call | | Two setup traps, then stable | Spread across every endpoint |
The claim-in-token approach is the right default when a permission is stable across a session and you want it enforced at the database boundary, not just in application code. It is not for permissions that change mid-session, where a token minted at login would go stale.
If you are on Supabase and find yourself checking the same permission in handler after handler, move it into the token. Write the auth hook, grant it correctly, keep the signing secret consistent, and let row-level security do the enforcing. The pattern is small and the two traps are the whole difficulty, so name them up front and they stop being mysterious.
We wire this kind of auth so the rules live at the boundary instead of scattered through application code. If your team wants its access model done once and done right, that's the work we do.
It's a function GoTrue (Supabase Auth) calls at a point in the auth lifecycle. The custom-access-token hook runs while the JWT is being minted, and lets you add or modify claims on the token. Implemented as a Postgres function, it can read your own tables to decide what claims a given user should carry, without touching your API code.
Why put permissions in the JWT instead of checking them per request?
Because a claim in the JWT is verified from the token's signature on every request with no extra database lookup, and it works anywhere the token does, including directly in row-level security policies. Checking permissions in your API on every call adds latency, duplicates logic across endpoints, and doesn't help database-level rules that run before your code.
What is the JWT_SECRET trap with custom claims?
If anything in your stack verifies tokens with a different signing secret than the one GoTrue used to mint them, verification fails or, worse, a mismatch leaves you debugging valid-looking tokens that are rejected. The signing secret has to be consistent across every service that issues or validates the JWT.
What grant does a Supabase auth hook need?
The auth admin role (supabase_auth_admin) has to be able to execute the hook function, and the function has to be able to read whatever tables it consults. Miss the grant and the hook silently doesn't run or errors during token minting, so users get tokens without the custom claim and your permission checks quietly fail open or closed.