Skip to content

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

  1. 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.
  2. 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").
  3. 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>.

MethodPathsupabase-jsPurpose
POST/factorsmfa.enrollEnroll a TOTP factor → { id, type, totp: { secret, uri, qr_code } }
GET/factorsmfa.listFactorsList the user’s factors (no secrets) → { all, totp }
POST/factors/{id}/challengemfa.challengeStart a challenge → { id, expires_at }
POST/factors/{id}/verifymfa.verifyVerify a code → aal2 session
DELETE/factors/{id}mfa.unenrollRemove 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 factors
await 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 challenge generates 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_ATTEMPTS wrong codes it locks for MFA_LOCKOUT_SECS, returning 429 mfa_too_many_attempts.
  • A successful verify elevates the session to aal2 exactly 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 (or 429 once 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.