Supabase 2FA / TOTP for Next.js SaaS
Two-factor authentication is one of those features procurement reviewers tick before they read your product description. Building it from scratch means generating TOTP secrets, persisting factor state, validating six-digit codes against a time window, and storing recovery codes hashed, a week of careful work to get wrong in subtle ways. Supabase Auth ships the cryptographic primitives so the work shrinks to enrolment UI, login challenge UI, and the surrounding lifecycle.
What Supabase Auth ships vs what you wire
Supabase Auth provides the MFA API surface: `auth.mfa.enroll()` returns a TOTP secret plus a QR-code provisioning URI, `auth.mfa.challenge()` opens a challenge against an enrolled factor, and `auth.mfa.verify()` validates a six-digit code. Factor state (verified, unverified) lives in `auth.mfa_factors`, scoped to the user. The cryptographic primitives are managed for you.
What does not ship is the UI: the enrolment dialog with QR-code rendering, the challenge step on the login page, the 'remember this device' option, the recovery-code download flow, and the account-settings panel where users can revoke factors. SaaSForge Core ships these React surfaces wired to the Supabase MFA API so the feature works end-to-end on clone day.
The enrolment flow, step by step
From the user's settings page, the app calls `enroll({ factorType: 'totp' })`. Supabase returns a `factor_id` plus a `qr_code` (data URL) and a `secret` (text fallback for password managers). The UI shows the QR for the authenticator app, asks the user to enter the first six-digit code, and calls `challenge()` + `verify()` to confirm the factor.
Recovery codes are a separate concern: Supabase does not generate them, so SaaSForge Core generates a set of one-time codes at enrolment, hashes them, and stores them in a `mfa_recovery_codes` table scoped to the user. The plaintext codes are shown once at enrolment with a download button; lost-phone recovery uses one of these codes to bypass the TOTP challenge.
"use client";
import { createClient } from "@/lib/supabase/client";
export async function enrollTotpFactor() {
const supabase = createClient();
const { data, error } = await supabase.auth.mfa.enroll({
factorType: "totp",
friendlyName: "Authenticator app",
});
if (error) throw error;
// data.totp.qr_code is a data URL to render in <img>
// data.totp.secret is the manual-entry fallback
return data;
}The login challenge, where most teams trip
After a user signs in with their primary factor (magic link or OAuth), Supabase returns a session, but if MFA is required, the session is in an `aal1` (authenticator assurance level 1) state. The app reads `auth.mfa.getAuthenticatorAssuranceLevel()` and, if `currentLevel` is below `nextLevel`, routes the user to a TOTP challenge page before granting access to the dashboard.
Getting this wrong looks like 'I enrolled 2FA but the app never asks me for a code', usually because the protected route only checks for a session, not for the assurance level. SaaSForge Core's middleware reads both, so a session without a verified TOTP step does not pass route guards on workspace pages.
Recovery, revocation, and admin overrides
Lost-phone recovery uses a stored recovery code: the user enters one of the one-time codes from enrolment, the app verifies it against the hashed list, and grants a session at the higher assurance level. The code is then marked used. If the user is out of codes, an Owner or Admin in their workspace can revoke their MFA factors via the admin panel, forcing re-enrolment on next sign-in.
Factor revocation calls `auth.mfa.unenroll({ factor_id })`. The audit log captures who revoked which factor and when, the same audit table the rest of SaaSForge Core writes to. The point is that 2FA is not a black box; lost-device and emergency-access workflows are part of the same support story as everything else.