Fix: output claims do not conform to expected schema
output claims do not conform to the expected schema: - (root): Invalid type. Expected: object, given: null means Supabase Auth got null back from your hook, not the claims you logged. Your function must return the entire event object with a populated claims field — returning only the claims you added, or letting jsonb_set see a NULL, produces exactly this error.
What makes this one maddening is the evidence pointing the wrong way. Your function runs. It logs valid JSON. It returns HTTP 200. And Auth still refuses the token.
supabase/auth issue #2038 — "Auth Hook 'Customize Access Token' Fails - 'output claims do not conform... given: null' Despite Hook Returning 200 OK & Valid JSON". Still open, and labelled
working-as-intendedplusdocumentation— which is the maintainers telling you this is a contract you're breaking, not a bug you're hitting.
What Auth actually validates
The custom access token hook runs immediately before a JWT is issued. Supabase hands your function an event object and expects one back. The contract has two parts, and both matter:
- The return value must be an object with a top-level
claimskey. Not the claims. An object containing them.(root): Invalid type. Expected: object, given: nullis the schema validator reporting that the root of what you returned was null. - The
claimsobject must still contain every required claim. Supabase's docs list them:iss,aud,exp,iat,sub,role,aal,session_id,email,phone,is_anonymous. You are adding to the claims Supabase gave you, never replacing them. Dropsuband you'll trade this error forinvalid claim: missing sub claimon every request.
Why does my hook return 200 but still fail?
Because 200 only says your function executed. The schema check happens after, on the value it returned — and there are three reliable ways to return null while everything looks healthy.
1. Returning the claims instead of the event. The most common one:
-- WRONG: returns the claims object, not the event that wraps it.
claims := event -> 'claims';
claims := jsonb_set(claims, '{app_metadata,role}', to_jsonb(user_role));
return claims;
2. jsonb_set propagating a NULL. If new_value is NULL, jsonb_set returns NULL for the whole document — which is precisely why Postgres ships a separate jsonb_set_lax whose only job is to let you choose different behaviour when new_value is NULL. So this line silently annihilates the entire event whenever the user has no row in your roles table:
-- user_role is NULL for any user without a row -> the result is NULL.
claims := jsonb_set(claims, '{app_metadata,role}', to_jsonb(user_role));
It works flawlessly for you, because you have a role. It fails for the first new signup.
3. A select ... into that finds no row. Same mechanism, one step earlier. select role into user_role from public.user_roles where user_id = ... leaves user_role NULL when there's no match, and PL/pgSQL doesn't raise — it just carries the NULL forward into step 2.
Notice that causes 2 and 3 both fail on exactly the users you didn't test with. That's why this error tends to show up in production days after the hook shipped.
The correct function
Read the claims out of the event, modify them, write them back into the event, and return the event:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
claims jsonb;
user_role text;
begin
select ur.role into user_role
from public.user_roles ur
where ur.user_id = (event ->> 'user_id')::uuid;
claims := event -> 'claims';
-- coalesce() is the whole fix for causes 2 and 3: never hand
-- jsonb_set a NULL, and never lose the app_metadata object.
if claims -> 'app_metadata' is null then
claims := jsonb_set(claims, '{app_metadata}', '{}'::jsonb);
end if;
claims := jsonb_set(
claims,
'{app_metadata,role}',
to_jsonb(coalesce(user_role, 'user'))
);
-- Write the modified claims back into the event, then return the EVENT.
return jsonb_set(event, '{claims}', claims);
end;
$$;
Then the grants, which are their own source of failures:
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook(jsonb)
to supabase_auth_admin;
-- Nobody else may call it.
revoke execute on function public.custom_access_token_hook(jsonb)
from authenticated, anon, public;
-- The hook reads this table as supabase_auth_admin, not as the user.
grant select on table public.user_roles to supabase_auth_admin;
create policy "auth admin can read user roles"
on public.user_roles
as permissive for select
to supabase_auth_admin
using (true);
Two details in that function are deliberate. security definer lets the hook read a table the calling user can't — and because of that, set search_path = '' is not optional. An unpinned search path on a definer function is a privilege-escalation path: anyone who can create objects in a schema on that path can make your hook resolve to a table they control, and it runs with the owner's rights.
Put the claim somewhere the user can't edit
While you're wiring custom claims, there's a related mistake that this error will never warn you about. The hook writes into app_metadata above for a reason — and policies must read it from there. This does not:
create policy "Admins can read all invoices"
on public.invoices
for select
to authenticated
using (
(auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'
);
user_metadata is writable by the end user via supabase.auth.updateUser({ data: ... }). Any authenticated user can call updateUser({ data: { role: 'admin' } }) and grant themselves the policy. Here's what GuardLayer reports on that migration:
- Criticalsupabase/migrations/20260824110000_admin_policy.sql:7
RLS policy trusts user-editable metadata
Never read user_metadata in a policy. Use app_metadata / raw_app_meta_data, which the user cannot write — or better, keep the role/tenant in a table your server controls and join against it, e.g. USING (EXISTS (SELECT 1 FROM public.memberships m WHERE m.user_id = auth.uid() AND m.org_id = org_id)).
app_metadata is not user-writable, which is the entire distinction — worth understanding properly if you're building roles into JWTs, because it's the difference between RBAC and self-service admin. Better still, keep the role in a table your server controls and join against it, which is also the shape that makes policies genuinely user-scoped.
Verify it end to end
Test the function directly, with a user id that has no role row — the case that breaks:
select public.custom_access_token_hook(
jsonb_build_object(
'user_id', '00000000-0000-0000-0000-000000000000',
'claims', jsonb_build_object('sub', '00000000-0000-0000-0000-000000000000',
'role', 'authenticated')
)
);
If that returns NULL, you've reproduced the bug in one query. A correct hook returns the full object with claims.app_metadata.role populated. Then sign in fresh — existing sessions keep their old JWT until the access token refreshes — and check the token:
const { data: { session } } = await supabase.auth.getSession();
JSON.parse(atob(session.access_token.split(".")[1])).app_metadata;
FAQ
Do I have to return every claim Supabase sent me?
Yes. Take event -> 'claims', add to it, and put it back. The required set (iss, aud, exp, iat, sub, role, aal, session_id, email, phone, is_anonymous) must survive intact.
My hook works for me but fails for new users. Why?
You have a row in the roles table and they don't, so a NULL reaches jsonb_set and nulls the whole document. Wrap the lookup in coalesce().
Why doesn't the error tell me which claim is wrong?
Because none of them are. (root) means the entire returned document was null — validation failed before it ever looked at individual claims.
Do custom claims apply to existing sessions immediately? No. Claims are baked into the JWT at issue time, so a user keeps their old claims until the access token refreshes or they sign in again. Plan for that when you change someone's role.
Should I use an HTTP hook instead of a Postgres function? Only if you genuinely need to call an external system. A Postgres function runs in-database on every token issue, which is faster and has fewer failure modes — an HTTP hook adds network latency to your login path.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.