Auth hooks are instance config, not rows — bind them per project

Migrations create the hook function, but the binding lives in project auth config. Unbound, every JWT lacks your claim and your API answers claims_invalid.

RLS & Auth

· Chapter

3

·

3

min read

The answer. A Custom Access Token hook has two halves. The function (public.custom_access_token_hook) is SQL, ships in your migrations, and lands anywhere you run them. The binding — "call this function when minting a token" — is project auth configuration, stored outside the database. Spin up a fresh project (sandbox, staging, a migration target), apply every migration, and the tokens it mints carry none of your custom claims, because nothing told Auth to call the hook. Every request then fails at your own claims validation, not at signature verification. Bind the hook per project — Dashboard → Authentication → Hooks, or the Management API — and put it on the environment checklist next to redirect URLs.

The pattern.

-- migration: the function half (lands everywhere you migrate)
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb language plpgsql stable as $$
declare claims jsonb := event->'claims'; org uuid;
begin
  select active_org_id into org from public.my_profiles where user_id = (event->>'user_id')::uuid;
  claims := jsonb_set(claims, '{app_metadata,active_org_id}', to_jsonb(org));
  return jsonb_set(event, '{claims}', claims);
end $$;
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;
# the binding half (per project — NOT in migrations)
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"hook_custom_access_token_enabled": true,
       "hook_custom_access_token_uri": "pg-functions://postgres/public/custom_access_token_hook"}'
# prove it: mint a session, decode the payload, expect your claim
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .app_metadata

The receipt. In our product (HarperFlow), building an isolated sandbox stack on 2026-07-30: every migration applied cleanly to a fresh free-tier project, the signup trigger ran, signing keys and JWKS verification passed — and every authenticated route returned 401 claims_invalid. jwtVerify was happy; the token was genuinely the project's. Our claims schema rejected it because app_metadata.active_org_id, the claim that scopes every RLS policy, was absent: the function existed in the database and was bound to nothing. Binding it in the project's auth settings fixed all routes at once, and the end-to-end run (login → OAuth connect → generate → publish) passed the same day. Time lost isn't recorded.

Watch out.

  • Same class of thing: MFA/passkey enrollment, site and redirect URLs, SMTP, rate limits. None of them travel with pg_dump.
  • Grant supabase_auth_admin execute on the function and revoke it from authenticated/anon; a hook callable by users is a claims-forging primitive.
  • Separate the two 401s in your API: verify_failed (signature/audience) versus claims_invalid (shape). The second points at the hook, not the keys.

Related: hosted-user-tokens-es256-not-hs256 · rls-test-set-local-role-and-jwt-claims · hosted-cutover-verified-table-by-table