MFA Verification Hook
You can add additional checks to the Supabase MFA implementation with hooks. For example, you can:
- Limit the number of verification attempts performed over a period of time.
- Sign out users who have too many invalid verification attempts.
- Count, rate limit, or ban sign-ins.
Inputs
Supabase Auth will send a payload containing these fields to your hook:
Field | Type | Description |
---|---|---|
factor_id | string | Unique identifier for the MFA factor being verified |
factor_type | string | totp or phone |
user_id | string | Unique identifier for the user |
valid | boolean | Whether the verification attempt was valid. For TOTP, this means that the six digit code was correct (true) or incorrect (false). |
_10{_10 "factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db",_10 "user_id": "3919cb6e-4215-4478-a960-6d3454326cec",_10 "valid": true_10}
Outputs
Return this if your hook processed the input without errors.
Field | Type | Description |
---|---|---|
decision | string | The decision on whether to allow authentication to move forward. Use reject to deny the verification attempt and log the user out of all active sessions. Use continue to use the default Supabase Auth behavior. |
message | string | The message to show the user if the decision was reject . |
_10{_10 "decision": "reject",_10 "message": "You have exceeded maximum number of MFA attempts."_10}
Your company requires that a user can input an incorrect MFA Verification code no more than once every 2 seconds.
Create a table to record the last time a user had an incorrect MFA verification attempt for a factor.
_10create table public.mfa_failed_verification_attempts (_10 user_id uuid not null,_10 factor_id uuid not null,_10 last_failed_at timestamp not null default now(),_10 primary key (user_id, factor_id)_10);
Create a hook to read and write information to this table. For example:
_66create function public.hook_mfa_verification_attempt(event jsonb)_66 returns jsonb_66 language plpgsql_66as $$_66 declare_66 last_failed_at timestamp;_66 begin_66 if event->'valid' is true then_66 -- code is valid, accept it_66 return jsonb_build_object('decision', 'continue');_66 end if;_66_66 select last_failed_at into last_failed_at_66 from public.mfa_failed_verification_attempts_66 where_66 user_id = event->'user_id'_66 and_66 factor_id = event->'factor_id';_66_66 if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then_66 -- last attempt was done too quickly_66 return jsonb_build_object(_66 'error', jsonb_build_object(_66 'http_code', 429,_66 'message', 'Please wait a moment before trying again.'_66 )_66 );_66 end if;_66_66 -- record this failed attempt_66 insert into public.mfa_failed_verification_attempts_66 (_66 user_id,_66 factor_id,_66 last_refreshed_at_66 )_66 values_66 (_66 event->'user_id',_66 event->'factor_id',_66 now()_66 )_66 on conflict do update_66 set last_refreshed_at = now();_66_66 -- finally let Supabase Auth do the default behavior for a failed attempt_66 return jsonb_build_object('decision', 'continue');_66 end;_66$$;_66_66-- Assign appropriate permissions and revoke access_66grant execute_66 on function public.hook_mfa_verification_attempt_66 to supabase_auth_admin;_66_66grant all_66 on table public.mfa_failed_verification_attempts_66 to supabase_auth_admin;_66_66revoke execute_66 on function public.hook_mfa_verification_attempt_66 from authenticated, anon, public;_66_66revoke all_66 on table public.mfa_failed_verification_attempts_66 from authenticated, anon, public;