🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs
Auth

Multi-Factor Auth

Add TOTP two-step verification, with one-time recovery codes, to your Floot app.

Your project ships a complete TOTP second factor: an enrolment wizard, a sign-in challenge, step-up re-verification for sensitive actions, one-time recovery codes, and three security notification emails. None of it runs until you turn it on.

TOTP is free on every Supabase plan

The [auth.mfa.totp] block in supabase/config.toml needs no paid plan:

supabase/config.toml
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10

# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = true
verify_enabled = true

Supabase gates other MFA-adjacent features behind Pro, Team or Enterprise (SMS/WhatsApp OTP delivery, organization-wide MFA enforcement), but TOTP itself works the same on supabase start and on a Free-tier hosted project.

Only TOTP is enabled. [auth.mfa.phone] ships enroll_enabled = false / verify_enabled = false, and [auth.mfa.web_authn] is commented out entirely: the Flutter UI has no screens for either.

Turn it on

Requires ENABLE_MFA

Multi-factor authentication is off in a new project. Set `ENABLE_MFA=true` in `.env.local` to use this section.

This flag lives in the app's .env.local only. There is no ENABLE_MFA in supabase/.env.local; the Edge Functions never consult it.

.env.local
ENABLE_MFA=true

It gates two things, and only two: the Multi Step Authentication row in Account → Security, and the sign-in MFA gate that routes a user with a verified factor to the challenge screen.

Turning it back off does not just hide the UI

The sign-in gate is inside the same if (Env.ENABLE_MFA). Flip the flag back to false on a project where accounts have already enrolled a factor and those accounts sign in at aal1 with no challenge at all: the factor is still enrolled, it is simply never asked for. Treat ENABLE_MFA=false as "MFA does not exist here", not as "MFA is temporarily hidden".

The recovery-code pepper

Recovery codes are never stored in plaintext. They are HMAC-SHA256'd with a pepper read from Supabase Vault under the lowercase name mfa_recovery_code_pepper, which is seeded from MFA_RECOVERY_CODE_PEPPER.

supabase/.env.local
# The value must be a cryptographically random secret of at least 32 characters.
MFA_RECOVERY_CODE_PEPPER=<48 random bytes, base64>

floot create generates a value unique to your project, so local development needs nothing from you. If you set up the kit without the CLI, generate one with openssl rand -base64 48. That value is for your local stack only: never copy .env.local values to production. A hosted project gets its own pepper, created in its Vault (see below).

It fails closed, and the symptom is useless

Both SQL entry points check the pepper and refuse to run without a valid one. In supabase/migrations/*_init.sql:

_pepper := app.vault_secret('mfa_recovery_code_pepper');

IF _pepper IS NULL OR char_length(_pepper) < 32 THEN
    RAISE EXCEPTION 'The mfa_recovery_code_pepper Vault secret is missing or invalid.';
END IF;

That message never reaches the user, on purpose: raw SQL text is logged, not echoed. What the caller gets is a bare 500, and the real cause is only in the function logs.

CallHTTPError codeLogged asWhat the user sees
Recovery code generation500DATABASE_ERRORUnmapped RPC error:A generic retry error on the last enrolment step
Recovery code redemption500UNEXPECTED_ERRORUnexpected error:"Something went wrong. Please try again."

If recovery codes 500 and nothing else in your project does, look at the pepper first.

On a hosted project

[db.vault] seeding is a local-only convenience. Nothing populates the Vault on a hosted project, so a deployment with MFA_RECOVERY_CODE_PEPPER set in the dashboard's Edge Function secrets but not in the Vault fails exactly as above. Generate a fresh value (openssl rand -base64 48), not the one in your .env.local, and create it yourself (Studio → Vault, or vault.create_secret()) under the lowercase name mfa_recovery_code_pepper.

Changing it locally

A plain stop and start keeps the old secret

[db.vault] in supabase/config.toml seeds the Vault when the database volume is created. A plain restart restores the saved volume and skips seeding, so your edited MFA_RECOVERY_CODE_PEPPER is ignored and the previous value survives. Destroy the volume instead:

Terminal
supabase stop --no-backup && supabase start

Rotating the pepper invalidates every recovery code already issued against the old one; they will never match again. That is intended, but it means a rotation should be paired with telling users to re-enrol.

The same [db.vault] block seeds four other secrets the same way (account_deletion_email_pepper, database_webhook_secret, storage_url, supabase_url), so this restart applies to all of them.

Enrolling an authenticator

Account → Security → Multi Step Authentication starts the enrolment wizard. Recovery codes are shown once, only when the just-confirmed factor is the account's first-ever verified one; second and later authenticators skip straight to success.

A user may enrol up to max_enrolled_factors authenticators (10). Unverified factors (abandoned enrolments) are never listed or counted. Removing the last verified factor turns multi-step authentication off for the account, because that is what it does.

Calling it again rotates the batch

Recovery codes are only ever (re)generated during the account's first-ever verified enrolment, which is why there is no "regenerate my codes" button anywhere in the shipped UI: a second call invalidates every code from the previous batch. If you add one, make the destructive rotation explicit to the user.

Signing in with a second factor

When a user with a verified factor signs in, the session lands at aal1 with aal2 reachable, and the app challenges for a code. It pre-selects the authenticator last used on this device, falling back to the oldest factor, and the failed-attempt count is scoped per authenticator rather than per session, so switching authenticators resets it. After repeated failures with more than one authenticator enrolled, a guidance card suggests switching to the app the code is actually coming from, the single most common cause of "my code is wrong".

Step-up verification

Sensitive actions do not merely require an aal2 session, they require a recent verification. ensureRecentMfaVerification returns immediately if the last TOTP verification is within a 5-minute freshness window (plus a 30-second clock-skew margin) and otherwise prompts for a code.

Removing a factor passes excludeFactorId, so the guard cannot be satisfied by a verification tied to the very authenticator being removed.

Call it before your own sensitive actions:

if (await ensureRecentMfaVerification(context)) {
  // proceed
}

The database enforces the same freshness for its own dangerous actions, such as deleting an organization; see Authorization.

Recovery codes reset MFA, they do not bypass it

This is the behaviour most likely to be reported as a bug, so it is worth being blunt about.

A redeemed recovery code signs the user out

Redeeming a code does not satisfy the challenge and does not produce an aal2 session. It starts an account-level MFA reset: every factor is deleted and every session is revoked. The user lands back on the sign-in screen with no second factor configured, and re-enrols from scratch.

The button is on the verification page, Use a recovery code, and warns "This will reset your MFA and sign you out of all devices." before submitting. An aal1 session is enough to redeem one: that is the whole point, since the user cannot complete a challenge, demanding aal2 here would make recovery impossible. Redeeming a code invalidates the entire batch (one code used means all six are spent), deletes every factor, verified or not, and signs the session out globally. The mfa-reset email goes out afterward; if it fails, the recovery still succeeds since it is already done by then, and the error is logged only.

Why it is built this way

A recovery code is a long-lived secret written down somewhere. If redeeming one minted an aal2 session, that piece of paper would be exactly as strong as the authenticator it is meant to back up, and an attacker who found it would get a fully-privileged session with the real second factor still quietly in place. Making redemption a loud, destructive reset (every factor gone, every session revoked, an email to the account owner) means a stolen code cannot be used silently.

Failure modes worth knowing

SituationResponseClient message
Unknown, already-used or revoked code401 INVALID_RECOVERY_CODE"This recovery code is invalid or has already been used."
5 failed submissions within 15 minutes429 RATE_LIMITED + Retry-After"Too many failed attempts. Please try again later."
A reset already pending for this user409 MFA_RECOVERY_PENDINGGeneric error

A malformed, unknown, already-used and revoked code all produce the identical 401, deliberately: the response must not tell an attacker which of those it was. All four count toward the lockout.

A recovery that crashed after its code was consumed is resumed on the next call without asking for (and burning) a second code, which is what the pending check is for.

Notification emails

Three emails relate to MFA, and they come from two different places.

EmailTemplateSent when
Security alert: A new sign-in method was addedsupabase/functions/send-email/_templates/mfa-factor-enrolledA user enrols a TOTP factor themselves
Security alert: A sign-in method was removedsupabase/functions/send-email/_templates/mfa-factor-unenrolledA user unenrols a TOTP factor themselves
Security alert: Your multi-factor authentication was resetsupabase/functions/send-email/_templates/mfa-resetA recovery code is redeemed

The first two are Supabase Auth notifications, switched on in supabase/config.toml:

supabase/config.toml
[auth.email.notification.mfa_factor_enrolled]
enabled = true

[auth.email.notification.mfa_factor_unenrolled]
enabled = true

Because [auth.hook.send_email] is enabled, Auth routes them through your own send-email function rather than sending them itself, which is what lets them be localized alongside the rest of your transactional email.

The third is not an Auth notification at all: the recovery-reset process renders and sends it directly, after the reset has completed. That is also why an account with no email address simply does not get it, rather than failing the recovery.

Seeing them locally

All three land in the Mailpit inbox at 127.0.0.1:54324. The first two need supabase functions serve running, since Auth calls your hook to render them; without it, enrolling a factor succeeds but no email is sent. Subjects and bodies are available in English and French, chosen from the user's users_prefs.language and falling back to English.

What's next?

On this page