# 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.

Source: https://supahost.app/docs/auth/mfa · Updated 2026-11-11

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:

```ts
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:

```ts
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:

```ts
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:

```ts
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](/kb/invalid-totp-code).

## 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:

```sql
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:

```sql
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:

```ts
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:

```ts
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:

```ts
// 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](/docs/auth/user-management).

## 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](https://supabase.com/docs/reference/javascript/auth-mfa-enroll).
