An eight-step tutorial covering when to require 2FA, the TOTP vs passkeys decision, the Supabase MFA enroll → challenge → verify flow, recovery codes, WebAuthn passkeys, and step-up authentication for sensitive actions — the canonical second-factor pattern for a solo SaaS founder.
Methodology. This tutorial synthesizes the Supabase MFA documentation, WebAuthn Level 3 spec, and Clerk’s public auth patterns as of May 2026. API shapes match Supabase’s supabase.auth.mfa.* JavaScript client and the WebAuthn browser API. APIs and dashboards change — verify against the official docs at supabase.com/docs/guides/auth/auth-mfa and w3.org/TR/webauthn-3 before shipping.
Two-factor authentication is the lowest-effort, highest-leverage security upgrade a SaaS can ship. Password databases leak, phishing kits stay one step ahead of training, and credential-stuffing attacks have effectively zero marginal cost — but a second factor turns a stolen password into noise. For most solo SaaS founders, the question is no longer whether to support 2FA but which factor types to expose and where in the product to require them.
This guide walks the canonical pattern: deciding which users see 2FA as optional vs forced, picking TOTP and passkeys (the two factor types worth supporting in 2026), wiring the Supabase MFA endpoints, generating recovery codes safely, registering passkeys via the WebAuthn API, and forcing a fresh MFA challenge before destructive actions. The code is Next.js + Supabase but the spec-level shapes apply to any modern auth stack.
The bigger question comes before any code. Forcing 2FA on every user is a friction tax; not offering it at all is a security debt. The honest decision rule, by audience:
Cover this decision up front because it constrains every subsequent design choice. If 2FA is mandatory for billing, you need step-up auth (Step 8). If 2FA is optional, you need a recovery-code flow that doesn’t lock people out (Step 6). The decision rule shapes the data model and the UX surface in equal measure.
Two factor types matter for a 2026 SaaS: TOTP (Time-based One-Time Password — the 6-digit codes from Google Authenticator, 1Password, Authy) and passkeys (WebAuthn-backed credentials bound to a device or synced through iCloud Keychain, Google Password Manager, or 1Password). SMS is a third option that the industry has largely written off; see the gotchas section for why.
| Factor | UX | Phishing resistance | Device portability | Recovery surface |
|---|---|---|---|---|
| TOTP | Scan QR, type 6 digits | Moderate (codes can be phished in real time) | High (any Authenticator app) | Recovery codes |
| Passkeys | One Touch/Face ID tap | Strong (origin-bound, unphishable) | High (synced via platform keychain) | Recovery codes + fallback factor |
| SMS | Wait for text, type 6 digits | Weak (SIM swap, SS7) | High (any phone) | Phone-number change |
The 2026 default: support both TOTP and passkeys. Passkeys are the user-friendly default path — they ride on the device’s biometric unlock, sync through the OS keychain, and are immune to credential-stuffing and phishing. TOTP is the universal fallback for users who don’t have a passkey-capable device, who want a portable authenticator they control, or who manage shared corporate accounts.
Don’t make users pick one. Offer both, let the user enroll both, and during sign-in surface whichever they have configured with passkey as the higher-priority option when available.
Before any client code runs, MFA has to be toggled on at the project level. In the Supabase dashboard navigate to Settings → Authentication → MFA. The toggle exposes the supabase.auth.mfa.* namespace on the JavaScript client; without it the enroll, challenge, and verify calls return a configuration error.
Two configuration knobs to set at the same time:
Confirm the enable by reading the aal (Authenticator Assurance Level) field on a session; if MFA is enabled and the user has no factors, sessions report aal: 'aal1', and once a factor is enrolled and verified the session upgrades to aal: 'aal2'. That distinction powers step-up auth in Step 8.
The enroll call requests a new factor, and Supabase returns a secret plus an otpauth:// URI that any Authenticator app can render as a QR code. The user scans the QR with their app, the app starts generating 6-digit codes, and the verify step (next) confirms the code matches before the factor is marked active.
// app/actions/enroll-totp.ts
'use server';
import { createClient } from '@/lib/supabase/server';
export type EnrollTotpResult = {
factorId: string;
qrCode: string;
secret: string;
uri: string;
};
export async function enrollTotp(): Promise<EnrollTotpResult> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('not authenticated');
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator app'
});
if (error) throw error;
return {
factorId: data.id,
qrCode: data.totp.qr_code,
secret: data.totp.secret,
uri: data.totp.uri
};
}
The client component renders the QR code (a base64-encoded SVG returned in qr_code) plus a fallback “enter this secret manually” text field. Show both: most users will scan, but a small fraction use password managers that ask for the raw secret. After display, transition to the verify input field for Step 5.
One subtlety worth noting: supabase.auth.mfa.enroll creates the factor row in an unverified state. If the user navigates away before completing verify, the factor is still there. List unverified factors via supabase.auth.mfa.listFactors() and let the user resume or delete them — otherwise stale factor rows accumulate.
Verification is a two-call dance: challenge opens a verification window, and verify submits the 6-digit code. The split exists so that a single factor can be challenged repeatedly without re-enrolling.
// app/actions/verify-totp.ts
'use server';
import { createClient } from '@/lib/supabase/server';
export async function verifyTotpEnrollment(
factorId: string,
code: string
): Promise<{ success: true }> {
const supabase = await createClient();
const { data: challenge, error: challengeErr } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeErr) throw challengeErr;
const { error: verifyErr } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code: code.trim()
});
if (verifyErr) throw verifyErr;
// On success the session AAL is upgraded to aal2 automatically.
return { success: true };
}
The 6-digit code from the Authenticator app is the time-based output of the TOTP algorithm using the secret from Step 4 as the key. The Supabase server accepts a small clock-drift window (the spec recommends ±1 30-second step), so a code typed during a window transition still validates. Don’t tighten this on your side — clock drift on real-world phones is a meaningful UX issue.
On a successful verify, the user’s session AAL upgrades to aal2. Persist a flag (or simply rely on listFactors()) to mark the account as MFA-enabled in the UI, and immediately hand the user their recovery codes from Step 6.
Recovery codes are the “I lost my phone” escape hatch. The canonical pattern: generate 8 to 12 cryptographically random codes, hash each one server-side with bcrypt or argon2, store only the hashes, and display the plaintext codes to the user exactly once during enrollment. The user is responsible for printing or saving them; the server never knows them again.
// lib/recovery-codes.ts
import { randomBytes, createHash } from 'node:crypto';
import { hash as argon2Hash } from '@node-rs/argon2';
import { createServiceClient } from '@/lib/supabase/service';
const CODE_COUNT = 10;
function formatCode(): string {
// 10 hex chars rendered as 5-5 with a hyphen, e.g. "a1b2c-3d4e5".
const raw = randomBytes(5).toString('hex');
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
}
export async function generateRecoveryCodes(userId: string) {
const codes = Array.from({ length: CODE_COUNT }, formatCode);
const supabase = createServiceClient();
// Hash each code with argon2 before persisting.
const rows = await Promise.all(
codes.map(async (code) => ({
user_id: userId,
code_hash: await argon2Hash(code),
consumed_at: null
}))
);
// Wipe any prior unused codes, then insert the new batch.
await supabase.from('mfa_recovery_codes')
.delete()
.eq('user_id', userId)
.is('consumed_at', null);
await supabase.from('mfa_recovery_codes').insert(rows);
// Return plaintext exactly once for display.
return codes;
}
The matching consume function takes a code on sign-in, walks the unused rows, and verifies via the argon2 comparison. Mark the row consumed (don’t delete it — you want an audit trail) and refuse the same code on retry. Codes should be single-use; if you accept reuse, leaked codes from a printed sheet stay live forever.
Three design rules worth pinning. Hash with argon2 or bcrypt — never plaintext, never a fast hash like SHA-256, because recovery codes are bearer credentials and brute-force on a fast hash is feasible. Show the codes once, immediately after the user verifies their first factor. And if the user later regenerates codes, invalidate the entire prior batch — never let two sets be valid at the same time.
Passkeys are the user-friendly path. The browser’s WebAuthn API (navigator.credentials.create for register, navigator.credentials.get for authenticate) talks to the platform keychain, which prompts the user with TouchID, FaceID, Windows Hello, or a hardware security key. The credential is bound to your origin, so phishing on a lookalike domain can’t harvest it.
For Supabase, the WebAuthn factor type is available in newer client versions; for finer control or to integrate with a custom backend, use @simplewebauthn/server. The canonical register-and-authenticate pattern, sourced from the WebAuthn Level 3 spec:
// app/passkeys/register.ts
'use client';
import { startRegistration } from '@simplewebauthn/browser';
export async function registerPasskey(userId: string, userEmail: string) {
// 1. Server generates registration options (challenge, RP info, user info).
const optsRes = await fetch('/api/passkeys/registration-options', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId, userEmail })
});
const options = await optsRes.json();
// 2. Browser prompts the user via the platform authenticator.
// On iOS this is FaceID/TouchID; on macOS Safari, TouchID; on
// Windows, Hello; on Android, fingerprint/face.
const attestation = await startRegistration(options);
// 3. Server verifies the attestation and persists the credential.
const verifyRes = await fetch('/api/passkeys/verify-registration', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId, attestation })
});
if (!verifyRes.ok) throw new Error('passkey registration failed');
return await verifyRes.json();
}
The server-side counterpart calls generateRegistrationOptions and verifyRegistrationResponse from @simplewebauthn/server, persists the resulting credential ID and public key on the user row, and returns success. Authentication is the mirror flow: generateAuthenticationOptions → navigator.credentials.get → verifyAuthenticationResponse.
The platform-vs-cross-platform choice in the registration options shapes the UX significantly. Most users want platform passkeys — the credential lives on the device and syncs through iCloud Keychain or Google Password Manager, so reinstalling the app keeps the passkey alive. Cross-platform passkeys (hardware security keys, USB tokens) are right for high-security tiers and admin accounts but feel clumsy for a typical end user. Set authenticatorSelection.authenticatorAttachment to 'platform' by default and offer cross-platform as an opt-in.
The most common 2FA design failure is enrolling once and never asking again. A long-lived session means a stolen device or an unlocked laptop still passes every check forever. The fix is step-up authentication: require a fresh MFA challenge before destructive or billing-impacting actions, regardless of when the user last authenticated.
Supabase models this through the AAL field on the session. After a successful MFA verify, AAL is aal2; after a configurable window (the canonical recommendation is 5 to 15 minutes for billing-grade actions), the application treats the session as “aal2-stale” and re-challenges. The middleware pattern:
// middleware/require-fresh-mfa.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createMiddlewareClient } from '@/lib/supabase/middleware';
// Routes that always require a fresh MFA challenge.
const STEP_UP_PREFIXES = [
'/settings/billing',
'/settings/security',
'/account/delete',
'/account/change-email'
];
const FRESH_WINDOW_MS = 5 * 60 * 1000; // 5 minutes.
export async function requireFreshMfa(req: NextRequest) {
if (!STEP_UP_PREFIXES.some((p) => req.nextUrl.pathname.startsWith(p))) {
return NextResponse.next();
}
const supabase = createMiddlewareClient(req);
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.redirect(new URL('/login', req.url));
}
const aal = session.user.aal ?? 'aal1';
const lastMfa = session.user.amr?.find((m) => m.method === 'totp' || m.method === 'webauthn');
const lastMfaAt = lastMfa ? lastMfa.timestamp * 1000 : 0;
const isFresh = aal === 'aal2' && (Date.now() - lastMfaAt) < FRESH_WINDOW_MS;
if (!isFresh) {
const next = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/mfa/challenge?next=${next}`, req.url));
}
return NextResponse.next();
}
The /mfa/challenge route opens a new challenge, asks for the TOTP code or a passkey tap, and on success redirects back to the originally requested URL. The user’s session AAL refreshes, the timestamp updates, and they pass the middleware on the next request.
The trade-off worth tuning is the fresh-window length. Five minutes is right for billing; a longer window (30 minutes to a few hours) is fine for less destructive admin actions. Tighten it for genuinely irreversible operations like “delete account” or “export all data” — under those, a 60-second window forces the user to MFA right before the click, which is what you want.
Disabling 2FA is itself a sensitive action. Route the disable flow through the step-up middleware so the user has to pass a fresh MFA challenge before removing their factor. The alternative — a single “disable” button that works on a logged-in session — means anyone with temporary device access can strip the protection in seconds.
Recovery codes are bearer credentials. Hash them server-side with bcrypt or argon2 (never plaintext, never a fast hash like SHA-256), display them exactly once during enrollment, and let the user regenerate the batch if they think the codes leaked. Regeneration must invalidate the entire prior set in the same transaction — never leave two batches valid simultaneously.
The TOTP spec (RFC 6238) recommends accepting a ±1 30-second window to absorb clock drift between server and authenticator. Supabase’s default does exactly this. Don’t override it to tighten the window — real-world phones drift by several seconds, and a stricter check makes the “type the code, get rejected” failure mode noisy. The marginal security gain is tiny; the UX cost is significant.
The authenticatorAttachment hint in navigator.credentials.create shapes the prompt the user sees. Set it to 'platform' by default so most users get the friendly TouchID/FaceID flow with iCloud or Google Password Manager sync. Cross-platform attachment (USB security keys) is the right default only for accounts with elevated risk — admin, billing, or developer-mode keys.
The hardest part of 2FA is what happens when a user loses every factor and every recovery code. Don’t auto-bypass — that defeats the whole point. The canonical approach: provide a helpdesk-mediated recovery path that requires identity verification (matching billing record, a video call, a notarized form for high-value accounts), and document a 24- to 72-hour delay before the bypass takes effect. The delay gives the legitimate owner time to interrupt an attacker who initiated the recovery first.
SIM-swap attacks are now routine, SS7 interception is a documented threat, and SMS adds zero phishing resistance. For high-value accounts, SMS 2FA is actually worse than passwords-only — it gives the attacker a single phone-number takeover point and the user a false sense of security. The 2026 default is to omit SMS entirely. The one exception: account-recovery notifications (read-only confirmations of a destructive action), where the SMS is a signal, not a credential.
Surface a warning during enrollment when the user is on what looks like a public or shared device (no platform credential available, incognito, no biometric authenticator). Encourage them to enroll later on a personal device. A passkey scoped to a public computer is one of the few ways to turn 2FA into a downgrade.
2FA is the highest-leverage security feature a SaaS can ship. The canonical 2026 pattern is to support both TOTP and passkeys, treat 2FA as optional for B2C, encouraged for B2B, and mandatory for billing and admin routes, hash recovery codes with argon2 and show them once, prefer platform passkeys for everyday users, and require a fresh MFA challenge (the AAL2 step-up pattern) before destructive actions. Build it in a week, gate behind a feature flag while you iterate the recovery flow, and never ship SMS as a second factor.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.