Multi-factor Auth (TOTP + SMS)
AnvilBase supports TOTP (time-based one-time password) multi-factor
authentication — the authenticator-app codes from Google Authenticator, 1Password,
Authy, etc. — and a phone (SMS) second factor, both through the
GoTrue-compatible /factors API. The wire shapes match supabase-js
auth.mfa.*, so a migrating Supabase app works unchanged.
How it works
- An authenticated user (a valid bearer access token is required for every
factor call) enrolls a TOTP factor. AnvilBase generates a secret and
returns an
otpauth://URI the user’s authenticator app scans. - The factor starts unverified. The user enters the first code; AnvilBase
verifies it, marks the factor verified, and mints an aal2-elevated
session (the JWT carries
"aal": "aal2"). - On later sign-ins your app challenges the factor and verifies the
current code to step the session up to
aal2.
Factors are stored per project in the mfa_factors table; secrets never leave the
server in a list response (only the one-time enroll response includes the secret so
the user can set up their authenticator).
Endpoints
All paths are under /v1/auth/<project_id>/auth/v1 and require
Authorization: Bearer <access_token>.
| Method | Path | supabase-js | Purpose |
|---|---|---|---|
POST | /factors | mfa.enroll | Enroll a TOTP factor → { id, type, totp: { secret, uri, qr_code } } |
GET | /factors | mfa.listFactors | List the user’s factors (no secrets) → { all, totp } |
POST | /factors/{id}/challenge | mfa.challenge | Start a challenge → { id, expires_at } |
POST | /factors/{id}/verify | mfa.verify | Verify a code → aal2 session |
DELETE | /factors/{id} | mfa.unenroll | Remove a factor |
SDK example
// 1. Enroll — render totp.uri as a QR code for the user to scan.const { data: factor } = await db.auth.mfa.enroll({ friendlyName: "My phone" })showQrCode(factor.totp.uri) // otpauth://totp/AnvilBase:user@example.com?...
// 2. Challenge + verify the first code to activate the factor (session → aal2).const { data: challenge } = await db.auth.mfa.challenge({ factorId: factor.id })const { data: session } = await db.auth.mfa.verify({ factorId: factor.id, challengeId: challenge.id, code: "123456", // current code from the authenticator})// session.access_token now carries "aal": "aal2".
// Later: list / remove factorsawait db.auth.mfa.listFactors()await db.auth.mfa.unenroll({ factorId: factor.id })The same methods exist on every SDK (mfa_enroll / mfa_verify / … in
Python/Rust/Elixir; MFAEnroll / MFAVerify / … in Go; mfaEnroll / mfaVerify
/ … in Kotlin/Swift).
Phone (SMS) factor
In addition to TOTP, a user can enroll a phone factor that receives a
6-digit code by SMS. Enroll with factorType: 'phone' and the E.164 number:
// 1. Enroll a phone factor (no QR — the code is delivered by SMS at challenge time).const { data: factor } = await db.auth.mfa.enroll({ factorType: "phone", phone: "+15551234567",})
// 2. Challenge sends an SMS containing the code…const { data: challenge } = await db.auth.mfa.challenge({ factorId: factor.id })
// 3. …and verify the received code to elevate the session to aal2.const { data: session } = await db.auth.mfa.verify({ factorId: factor.id, challengeId: challenge.id, code: "123456", // the code from the SMS})- Requirements. Phone MFA requires the project’s phone auth enabled and
an SMS provider configured (see Phone / SMS auth). If
either is missing, enroll and challenge return
422 mfa_phone_not_enabled. - Per-challenge code. Unlike TOTP (a permanent seed), a phone factor has no
stored seed. Each
challengegenerates a fresh 6-digit code, stores it encrypted at rest (enc:v1:, same scheme as the TOTP seed) with a 5-minute TTL, and delivers it via SMS. A verify after the TTL fails; the code is single-use (cleared on a successful verify). - Same lockout. The phone factor shares the identical brute-force lockout as
TOTP (below): after
MFA_MAX_ATTEMPTSwrong codes it locks forMFA_LOCKOUT_SECS, returning429 mfa_too_many_attempts. - A successful verify elevates the session to
aal2exactly like TOTP.
Gating on aal2
A successful verify mints a session whose JWT carries "aal": "aal2". Use this in
RLS or app logic to gate sensitive operations on a completed second factor — for
example, require request.jwt.claims->>'aal' = 'aal2' in a policy on a sensitive
table. Sessions without the claim are treated as aal1.
The "aal": "aal2" claim persists across token refresh for the session’s
lifetime. When supabase-js auto-refreshes the access token in the background,
the refreshed token keeps aal2, so RLS and app gates on aal2 remain enforced
throughout the session (previously the session silently downgraded to aal1
after the first refresh, ~1 h in — now fixed).
Audit events
mfa.enabled fires the first time a factor is verified, mfa.verified on every
successful verify, and mfa.disabled on unenroll. See
Auth audit events.
Brute-force protection
A 6-digit TOTP code has a small keyspace, so POST /factors/:id/verify is
rate-limited per factor. After 5 consecutive wrong codes
(MFA_MAX_ATTEMPTS) the factor is locked for 15 minutes (MFA_LOCKOUT_SECS)
and every verify — even with the correct code — returns
429 mfa_too_many_attempts until the lock expires. A successful verify before the
threshold resets the counter. A lockout records a login.failure audit event with
MFA metadata. Both thresholds are overridable via the MFA_MAX_ATTEMPTS /
MFA_LOCKOUT_SECS env vars.
Secret storage
TOTP seeds are encrypted at rest — the mfa_factors.secret column holds an
enc:v1: AES-256-GCM value (key HKDF-derived from CONTROL_PLANE_SECRET, the
same scheme as OAuth client secrets and SMTP passwords). A read of the project
database never discloses a second-factor seed; it is decrypted only at
verify-time and never returned to clients except once, in the enroll response, so
the user can set up their authenticator.
Notes
- Codes are checked with a ±1 time-step (30 s) window to tolerate clock skew.
- A wrong code returns
401(or429once locked) and does not mark the factor verified. - Each factor call is scoped to the bearer token’s user — one user can never read or remove another user’s factors.
Next: Auth audit events.