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:
[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 = trueSupabase 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.
ENABLE_MFA=trueIt 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.
# 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.
| Call | HTTP | Error code | Logged as | What the user sees |
|---|---|---|---|---|
| Recovery code generation | 500 | DATABASE_ERROR | Unmapped RPC error: | A generic retry error on the last enrolment step |
| Recovery code redemption | 500 | UNEXPECTED_ERROR | Unexpected 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:
supabase stop --no-backup && supabase startRotating 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
| Situation | Response | Client message |
|---|---|---|
| Unknown, already-used or revoked code | 401 INVALID_RECOVERY_CODE | "This recovery code is invalid or has already been used." |
| 5 failed submissions within 15 minutes | 429 RATE_LIMITED + Retry-After | "Too many failed attempts. Please try again later." |
| A reset already pending for this user | 409 MFA_RECOVERY_PENDING | Generic 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.
| Template | Sent when | |
|---|---|---|
| Security alert: A new sign-in method was added | supabase/functions/send-email/_templates/mfa-factor-enrolled | A user enrols a TOTP factor themselves |
| Security alert: A sign-in method was removed | supabase/functions/send-email/_templates/mfa-factor-unenrolled | A user unenrols a TOTP factor themselves |
| Security alert: Your multi-factor authentication was reset | supabase/functions/send-email/_templates/mfa-reset | A recovery code is redeemed |
The first two are Supabase Auth notifications, switched on in
supabase/config.toml:
[auth.email.notification.mfa_factor_enrolled]
enabled = true
[auth.email.notification.mfa_factor_unenrolled]
enabled = trueBecause [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.