Skip to main content

Feature deep-dive · SaaSForge Core

Supabase 2FA / TOTP for Next.js SaaS

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.

Enrolling a TOTP factor on the client
"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.

Frequently asked

Does Supabase support SMS or push-based 2FA?
Not natively for SMS, and not at all for push. Supabase's MFA API focuses on TOTP via authenticator apps (Google Authenticator, 1Password, Authy, etc.), which is the security industry's preferred second factor, SMS is phishable and being deprecated by NIST guidance. If you need SMS specifically, you would wire a third-party provider (Twilio Verify, AWS SNS) and persist factor state yourself.
Can I enforce 2FA for an entire workspace?
Yes. SaaSForge Core ships a workspace setting that flags `mfa_required = true`; the route guard then refuses to render the dashboard for any member whose session has not satisfied the TOTP challenge. The first sign-in after the flag flips routes the user into enrolment before they can continue.
Are recovery codes mandatory?
They are not enforced by Supabase, but they are strongly recommended. SaaSForge Core's enrolment UI generates and shows a set of one-time codes at the same time as the factor is verified, with a download-and-confirm step before the user can leave the settings page. Without recovery codes, lost-phone recovery requires an admin override every time.
What about WebAuthn / passkeys?
Supabase Auth's WebAuthn support is at a different maturity than TOTP at the time of writing; SaaSForge Core ships TOTP as the default 2FA. Adding passkeys later is layered on the same MFA factor model, so the surrounding UI (enrolment flow, challenge page) keeps the same shape.
Ships in SaaSForge Core

See SaaSForge Core. Skip the deliberation.

Full source code. Lifetime updates. Polar Merchant-of-Record checkout. Private GitHub repo on purchase.