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.

1 When should you require 2FA?

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:

  • Optional for B2C. Consumer apps with low individual blast radius (photo editors, journaling tools, indie productivity apps) should offer 2FA in account settings but never force it. Mandating 2FA on signup costs you onboarding conversion and protects very little.
  • Strongly encouraged for B2B. Business-customer SaaS should default-on prompt for 2FA at first login, nudge again on day 7, and surface a security-score badge in account settings. Most B2B users will enable it if you make the path obvious.
  • Required for admin and billing routes — always. Anyone who can change a payment method, invite team members, delete data, export the user table, or modify role assignments should be forced through 2FA before the action lands. This is non-negotiable in 2026; it’s the bar every security questionnaire assumes.

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.

2 TOTP vs passkeys

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.

3 Enable MFA in Supabase Auth

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:

  • Maximum enrolled factors per user. Default is 10. A reasonable solo-SaaS value is 5 — high enough to allow a passkey on phone, a passkey on laptop, and a TOTP on an Authenticator app, with two slots in reserve.
  • Allow phone factors. Leave this off. SMS-based 2FA is a downgrade attack surface (see the gotchas section); the dashboard option exists for backward compatibility but you don’t want it on for new projects.

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.

4 Build the TOTP enrollment flow

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.

5 Verify enrollment

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.

6 Generate and persist recovery codes

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.

7 Add the passkey/WebAuthn flow

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: generateAuthenticationOptionsnavigator.credentials.getverifyAuthenticationResponse.

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.

8 Step-up auth for sensitive actions

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.

Gotchas and operational notes

Don’t let users disable 2FA without re-authenticating

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.

Store recovery codes once, hashed

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.

Time drift: don’t make TOTP stricter than the spec

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.

Passkey UX: prefer platform passkeys

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.

Account recovery without MFA: email-via-helpdesk only

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.

SMS 2FA: don’t

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.

Don’t enroll on a shared device

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.

Summary
decide policy → TOTP + passkeys → enroll → verify → recovery codes → WebAuthn → step-up AAL2

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.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.