Auth

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:

FieldTypeDescription
factor_idstringUnique identifier for the MFA factor being verified
factor_typestringtotp or phone
user_idstringUnique identifier for the user
validbooleanWhether 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.

FieldTypeDescription
decisionstringThe 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.
messagestringThe 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.


_10
create 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:


_66
create function public.hook_mfa_verification_attempt(event jsonb)
_66
returns jsonb
_66
language plpgsql
_66
as $$
_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
_66
grant execute
_66
on function public.hook_mfa_verification_attempt
_66
to supabase_auth_admin;
_66
_66
grant all
_66
on table public.mfa_failed_verification_attempts
_66
to supabase_auth_admin;
_66
_66
revoke execute
_66
on function public.hook_mfa_verification_attempt
_66
from authenticated, anon, public;
_66
_66
revoke all
_66
on table public.mfa_failed_verification_attempts
_66
from authenticated, anon, public;