Supabase shipped auth hooks (Send SMS, Send Email, MFA Verification Attempt, Password Verification, Custom Access Token) over the past year. When they first rolled out I kept everything in Postgres triggers because that's what I knew. I recently moved 3 things over and can tell you when each pattern wins.
what auth hooks are
Think of them as middleware that runs at specific points in the auth flow. You define an HTTP endpoint (or a Postgres function); Supabase calls it with payload; you return a modified or augmented response.
Types in 2026:
Custom Access Token: modify the JWT before it's issued
Send Email: control which emails get sent and what they contain
Send SMS: same but for SMS
MFA Verification Attempt: gate MFA attempts
Password Verification Attempt: gate sign-ins
what database triggers are
Triggers on auth.users fire when rows change. They're reactive. The auth event already happened; you're observing it.
the key difference
Auth hooks run during the auth flow. They can change behavior. Triggers run after rows change. They can only react.
when auth hooks win
You need to add custom claims to the JWT (Custom Access Token hook is the canonical way)
You need to control which user sees which email (Send Email hook lets you skip emails entirely for certain users)
You want to enforce policy before allowing sign-in (Password Verification hook can deny attempts)
You need to send emails through your own infrastructure (Send Email hook bypasses Supabase's email sender entirely)
when triggers win
You're recording the event (audit log, user_events table)
You're updating related tables (initialize a workspace when a user signs up)
You're sending downstream notifications that aren't blocking the auth flow
You want to react to ALL changes (including admin actions, password resets, etc.)
concrete example: custom claims in the JWT
Triggers can update app_metadata after a user signs up, but the JWT was already issued before the trigger fired. The user has to sign out and back in to get a JWT with the new claims.
Auth hook (Custom Access Token):create or replace function oidc_custom_claims(event jsonb)
returns jsonb as $$
declare
claims jsonb;
user_id uuid := (event ->> 'user_id')::uuid;
user_record record;
begin
claims := event -> 'claims';
select raw_app_meta_data into user_record
from auth.users
where id = user_id;
claims := claims || jsonb_build_object(
'tier', user_record.raw_app_meta_data ->> 'tier',
'workspace_id', user_record.raw_app_meta_data ->> 'workspace_id'
);
return jsonb_build_object('claims', claims);
end;
$$ language plpgsql;
Claims are added every time a token is minted. No sign-out needed.
the gotcha that bit me
Auth hooks add latency to the auth flow. If your hook does a slow query, every sign-in slows down. Keep them fast (single-row lookups, no joins, no external APIs).
Triggers don't add latency to the auth flow because they fire after the user is already signed in. You can do slow work in a trigger without affecting the user's experience.
the actual rule I landed on
claims in the JWT → Custom Access Token hook
email-flow control → Send Email hook
recording the event → database trigger
updating related tables → database trigger
both: yes, you'll have both. They're not mutually exclusive.
What auth hooks are people using in production? Specifically curious about the Send SMS hook for non-standard SMS providers.
The user discusses the advantages of using Supabase's auth hooks over traditional database triggers, highlighting scenarios where each approach is beneficial. Auth hooks can modify JWTs and control email/SMS flows during the authentication process, while triggers are useful for post-event actions like logging or updating related tables. The thread also explores the use of custom SMS providers and strategies to mitigate issues like SMS pumping.
Trigger vs hook split is basically what I use too, so I'll just take the SMS question since nobody writes about that one. Stuff I've actually seen in prod with Send SMS:Routing for cost and deliverability is the obvious reason. Twilio's fine in US/EU but pricing and delivery rates get rough across a lot of APAC and LatAm, so people swap in Plivo, Telnyx, Bird, or a local aggregator (MSG91 or Gupshup if you're sending to India). Owning the send side is the whole point, you get to pick per region.Some teams don't even send SMS from it. One I worked with pushes the OTP over WhatsApp Business in markets where SMS just doesn't land, and only falls back to SMS if that fails. You get the phone number and the code in the payload so the channel is yours. The one I'd actually flag for you though is fraud. The real reason to own this path isn't the provider, it's SMS pumping. Bots hammer your OTP endpoint with numbers on premium-rate ranges and someone collects the revenue share. Supabase's built-in rate limits won't catch that. In the hook I run the number through libphonenumber, drop country codes I don't serve plus the known pumping ranges, and cap attempts per number and per IP before the provider ever gets called. Killed a slow monthly bleed doing exactly that on a previous app.On your latency point: if you're running the hook as an edge function, watch cold starts. They stack on top of the provider round trip and the whole thing sits in the OTP request path. A 3 second cold start on "text me a code" just feels broken to the user. For the SMS hook specifically I'd keep a warm endpoint instead. Your keep-hooks-fast rule matters double here since you're also blocked on an API you don't control. One thing I still haven't settled: how hard to fail when the provider errors. Right now I return the error and let them retry, but that leaks provider downtime straight onto the user. Anyone doing async send with optimistic success? Curious how you reconcile a code that never arrives.
[ Removed by Reddit ]