Multi-factor authentication (TOTP)

Add authenticator-app MFA to a SupaHost project with the supabase.auth.mfa API: enroll a TOTP factor, challenge at sign-in, check the aal claim, and enforce MFA in RLS policies.

Multi-factor authentication asks users for a second proof of identity after their password, magic link or OAuth sign-in. On SupaHost the second factor is TOTP: a six-digit code from an authenticator app such as 1Password, Google Authenticator or Authy, which changes every 30 seconds.

The TOTP API is part of Supabase Auth itself, available through supabase.auth.mfa in the client library. There's nothing to switch on and no MFA setting in the SupaHost dashboard. Phone (SMS) factors aren't available.

How it fits together

Every access token has an aal (authenticator assurance level) claim:

  • aal1: the user signed in with one factor (password, magic link, OAuth, and so on).
  • aal2: the user has also verified a TOTP code in this session.

Your app decides when to ask for the second factor, and your RLS policies decide which data needs aal2. Auth tracks the factors and upgrades the session.

1. Enroll a factor

On a security settings page, start enrollment. Auth returns a QR code (as an SVG data URL) and the secret for manual entry:

const { data, error } = await supabase.auth.mfa.enroll({
  factorType: 'totp',
  friendlyName: 'My phone',
})

const factorId = data!.id
showQrCode(data!.totp.qr_code)  // <img src={qr_code} />
showSecret(data!.totp.secret)   // for typing into the app by hand

The factor stays unverified until the user proves they scanned it. Ask for the current code and verify it:

const { data: challenge } = await supabase.auth.mfa.challenge({ factorId })

const { error } = await supabase.auth.mfa.verify({
  factorId,
  challengeId: challenge!.id,
  code: codeFromInput,
})

A successful verify marks the factor as verified and upgrades the current session to aal2. challengeAndVerify({ factorId, code }) does both calls in one.

2. Ask for the code at sign-in

After a normal sign-in, check whether the user has a factor that they haven't used yet in this session:

const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()

if (data?.nextLevel === 'aal2' && data.currentLevel !== 'aal2') {
  showCodeScreen()
}

currentLevel is what the session has now; nextLevel is what it could reach. A user with no verified factors has aal1 for both, so they go straight in.

On the code screen:

const { data: factors } = await supabase.auth.mfa.listFactors()
const totp = factors!.totp[0]   // verified TOTP factors only

const { error } = await supabase.auth.mfa.challengeAndVerify({
  factorId: totp.id,
  code: codeFromInput,
})

if (error?.code === 'mfa_verification_failed') {
  // wrong or expired code; let them try again
}

If codes are consistently rejected, the phone's clock is usually off. See Invalid TOTP code entered.

3. Enforce MFA in the database

Checks in your UI are convenient but not security: someone with a stolen password still has an aal1 token that can call the Data API directly. Enforce the rule in RLS.

A restrictive policy is combined with and on top of your normal (permissive) policies, so it's a good fit. To require aal2 for every user on the invoices table:

create policy "Invoices need MFA"
on public.invoices
as restrictive
for all
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');

More often you'll want MFA only for users who've set it up, so people without a factor aren't locked out. A small helper function checks for a verified factor:

create function public.user_has_mfa()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from auth.mfa_factors
    where user_id = (select auth.uid())
      and status = 'verified'
  );
$$;

create policy "MFA users must use MFA"
on public.invoices
as restrictive
for all
to authenticated
using (
  (select auth.jwt() ->> 'aal') = 'aal2'
  or not (select public.user_has_mfa())
);

The function is security definer so it can read auth.mfa_factors, and it only ever answers for the calling user.

Don't forget as restrictive. Without it, the policy is permissive and widens access instead of narrowing it.

Server-side checks

On a server, read the claim from a verified token rather than from the cookie directly:

const { data } = await supabase.auth.getClaims()
if (data?.claims.aal !== 'aal2') redirect('/verify-mfa')

Removing a factor

The user can remove their own factor. Auth requires an aal2 session to remove a verified factor, so ask for a code first:

await supabase.auth.mfa.unenroll({ factorId })

If a user loses their phone, there are no built-in recovery codes. Two common approaches:

  • Let users enroll a second authenticator (another device or a password manager) as a backup.
  • Remove the factor for them from your server after you've verified who they are by other means:
// Server only, with the service_role key
await admin.auth.admin.mfa.deleteFactor({ id: factorId, userId })

Find a user's factors with admin.auth.admin.mfa.listFactors({ userId }). See Managing users.

What isn't available on SupaHost

  • Phone or SMS factors. Phone auth is turned off, and the Phone MFA add-on isn't sold.
  • Dashboard settings for MFA, such as limiting the number of factors per user. Auth's defaults apply.

For every method and return field, see the MFA section of the supabase-js reference.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us