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:
_58create function public.hook_mfa_verification_attempt(event jsonb)_58 returns jsonb_58 language plpgsql_58as $$_58 declare_58 last_failed_at timestamp;_58 begin_58 if event->'valid' is true then_58 -- code is valid, accept it_58 return jsonb_build_object('decision', 'continue');_58 end if;_58_58 select last_failed_at into last_failed_at_58 from public.mfa_failed_verification_attempts_58 where_58 user_id = event->'user_id'_58 and_58 factor_id = event->'factor_id';_58_58 if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then_58 -- last attempt was done too quickly_58 return jsonb_build_object(_58 'error', jsonb_build_object(_58 'http_code', 429,_58 'message', 'Please wait a moment before trying again.'_58 )_58 );_58 end if;_58_58 -- record this failed attempt_58 insert into public.mfa_failed_verification_attempts_58 (_58 user_id,_58 factor_id,_58 last_refreshed_at_58 )_58 values_58 (_58 event->'user_id',_58 event->'factor_id',_58 now()_58 )_58 on conflict do update_58 set last_refreshed_at = now();_58_58 -- finally let Supabase Auth do the default behavior for a failed attempt_58 return jsonb_build_object('decision', 'continue');_58 end;_58$$;_58_58-- Assign appropriate permissions and revoke access_58grant all_58 on table public.mfa_failed_verification_attempts_58 to supabase_auth_admin;_58_58revoke all_58 on table public.mfa_failed_verification_attempts_58 from authenticated, anon, public;