SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Jun 2, 2026·Security·SecureStartKit Team

Supabase MFA Recovery: 5 Lost-Device Failure Modes [2026]

Supabase MFA recovery without recovery codes: backup TOTP factor, audited service_role reset, AAL-preserving rebinding, and 5 lost-device failure modes.

Summarize with AI

On this page

  • Table of contents
  • Why does Supabase MFA recovery need its own audit?
  • How does Supabase want you to handle a lost MFA device?
  • What goes wrong with BYO recovery codes?
  • How do you wire support-side factor reset with an audit trail?
  • What are the 5 most common MFA recovery failure modes?
  • Failure 1: Shipping with no backup factor and counting on support to fix it
  • Failure 2: Application-layer recovery codes stored in plaintext
  • Failure 3: Factor mutations allowed indefinitely on a long-lived AAL2 session
  • Failure 4: Support-staff factor reset with no audit trail
  • Failure 5: Trusted-device cookies that survive factor rotation
  • How does this fit your overall auth architecture?

On this page

  • Table of contents
  • Why does Supabase MFA recovery need its own audit?
  • How does Supabase want you to handle a lost MFA device?
  • What goes wrong with BYO recovery codes?
  • How do you wire support-side factor reset with an audit trail?
  • What are the 5 most common MFA recovery failure modes?
  • Failure 1: Shipping with no backup factor and counting on support to fix it
  • Failure 2: Application-layer recovery codes stored in plaintext
  • Failure 3: Factor mutations allowed indefinitely on a long-lived AAL2 session
  • Failure 4: Support-staff factor reset with no audit trail
  • Failure 5: Trusted-device cookies that survive factor rotation
  • How does this fit your overall auth architecture?

Supabase MFA recovery does not work the way most auth systems handle it. There are no recovery codes returned at enroll time and no built-in lost-device flow. The Supabase recommendation is structural: register a second TOTP factor at enroll time (up to ten per user [4]) and treat the admin client's auth.admin.mfa.deleteFactor() as the audited escape valve when even that fails [3]. Five failure modes show up across MFA recovery audits, all of them application-layer mistakes that map to a missing primitive in Supabase's API surface.

This is the recovery and lost-device companion to the broader OAuth, magic links, and MFA guide, which covers enrollment, AAL2 step-up, and Row Level Security policies that gate sensitive operations. That guide assumes the happy path: the user keeps their authenticator and verifies cleanly. This post covers the unhappy paths the audits keep finding.

TL;DR:

  • Supabase does not ship recovery codes. The official recommendation is to enroll a second TOTP factor at the same time as the first; up to ten factors are allowed per user [1][4]. Recovery codes that look like Supabase primitives in third-party tutorials are always application-layer.
  • auth.admin.mfa.deleteFactor() is the support-side escape valve. Verbatim from the docs: "Deletes a factor on a user. This will log the user out of all active sessions if the deleted factor was verified" [3]. Use it from a trusted Server Action, never from a client. Pair it with a row in an audit table or the action leaves no trace.
  • Unenroll needs AAL2. Verbatim: "A user has to have an aal2 authenticator level in order to unenroll a verified factor" [2]. Self-service factor management routes must step the session up to AAL2 first; otherwise the call fails and the UI looks broken.
  • NIST SP 800-63B treats single-channel recovery as an assurance downgrade. Account recovery via the same email that already controls password recovery does not preserve AAL2; it collapses to AAL1 [6]. Build the application-layer recovery flow with that constraint visible, not hidden.
  • The 5 failure modes, in audit frequency order: shipping with no backup factor and counting on support to fix it, storing application-layer recovery codes in plaintext, allowing factor mutations on a long-lived AAL2 session without re-authentication, support-staff factor resets with no audit trail, and trusted-device cookies that survive factor rotation.

Table of contents

  • Why does Supabase MFA recovery need its own audit?
  • How does Supabase want you to handle a lost MFA device?
  • What goes wrong with BYO recovery codes?
  • How do you wire support-side factor reset with an audit trail?
  • What are the 5 most common MFA recovery failure modes?
  • How does this fit your overall auth architecture?

Why does Supabase MFA recovery need its own audit?

Every other auth system you have used probably ships recovery codes alongside enrollment. Auth0 does. Clerk does. Firebase Authentication does. The mental model nearly every developer brings to a Supabase MFA implementation is "give the user ten one-time codes at enrollment, store them hashed, let them spend codes to recover." That mental model is wrong here. Supabase's MFA documentation lists exactly two factor types (MFA via TOTP and Phone) [1] and exposes three call patterns (enroll, challenge, verify). Recovery codes are not on the surface at all.

What Supabase does ship is a different recovery primitive: up to ten TOTP factors per user, each one a fully equivalent second authenticator. The official troubleshooting guidance is to register a second factor at enroll time, scope it to a different device or password manager, and use it if the primary is lost. That works, but it shifts a problem onto your application: most users will skip the second-factor enrollment if you make it optional, and you will end up with users who own exactly one TOTP secret and no recovery path.

The audit picture is consistent across teams. The first lost-device support ticket arrives within the first month of shipping MFA. The team improvises a recovery path: a "contact support" form that hits a Server Action which calls auth.admin.mfa.deleteFactor(). Some teams then layer on application-layer recovery codes, store them in a text[] column on profiles, and create a second class of bypass material that may or may not be hashed. The result is a recovery surface that looks reasonable from the outside and quietly violates two or three of OWASP A07's identification and authentication failure patterns [9].

The architectural fix is to make the Supabase-recommended path (backup TOTP at enroll time) the default UX, then design the support-side reset as an audited, AAL-aware fallback rather than the primary recovery channel. That order matters. If support reset is the primary path, every account inherits an "ask support nicely" attack surface; if support reset is the audited fallback after backup-factor recovery and self-service unenroll, the attack surface is contained.

How does Supabase want you to handle a lost MFA device?

The Supabase recommendation is to register a backup TOTP factor at enroll time. Up to ten factors can be active simultaneously. The friendly-name field on enroll is meant for this: "Primary phone" and "Backup (password manager)" mark the intent in the dashboard and in listFactors() responses [1]. When the primary is lost, the user signs in with the password, presents the backup factor at challenge, and re-establishes AAL2 without a support touch.

The enrollment UX that gets this right has three properties. First, the prompt to enroll a second factor is part of the same flow as the first, not a separate "manage MFA" page the user has to discover. Second, the secret/QR for the second factor is displayed in a context that makes "save this in a different place than the first" obvious (password manager card, printable PDF, or both). Third, the success state of MFA setup is "you have 2 factors" rather than "you have 1 factor" so that a user with one factor sees a persistent prompt to add the second.

The Server Action that enrolls a factor lives in the standard pattern this template uses for auth actions. The session cookie identifies the user; no userId parameter comes from the form. The Supabase docs are explicit about why: any time a user-ID parameter shows up in a Server Action signature, you are trusting the request to assert who the user is, which the validated session already does correctly [1]. The pillar's Failure 5 deep-dive covers the broader pattern. For factor enrollment specifically:

'use server'

import { createServerClientWithCookies } from '@/lib/supabase/server'
import { rateLimit } from '@/lib/rate-limit'
import { z } from 'zod'

const enrollSchema = z.object({
  friendlyName: z.string().min(1).max(64),
})

export async function enrollBackupTotp(formData: FormData) {
  const { success } = await rateLimit('mfa-enroll', 5, 60)
  if (!success) {
    return { error: 'Too many enrollment attempts. Try again in a minute.' }
  }

  const parsed = enrollSchema.safeParse({
    friendlyName: formData.get('friendlyName'),
  })
  if (!parsed.success) {
    return { error: parsed.error.errors[0].message }
  }

  const supabase = await createServerClientWithCookies()
  const { data: claims, error: claimsError } = await supabase.auth.getClaims()
  if (claimsError || !claims) {
    return { error: 'Not authenticated' }
  }

  // The session cookie is the identity. No userId parameter from the client.
  const { data, error } = await supabase.auth.mfa.enroll({
    factorType: 'totp',
    friendlyName: parsed.data.friendlyName,
  })

  if (error) return { error: error.message }
  return { factorId: data.id, qrCode: data.totp.qr_code, secret: data.totp.secret }
}

Two details earn their place here. The rate limiter prevents an attacker who has compromised an AAL1 session from spinning up ten attacker-controlled factors quickly enough to dilute the legitimate factor list. The getClaims() call validates the JWT signature against the cached JWKS before the enroll runs; without that, the action would happily enroll factors for a session whose signing key has been rotated out. The JWT and session management guide covers the getClaims-vs-getSession decision and the cookie semantics it depends on.

The verify step returns the AAL2 session. From the docs verbatim: "Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to aal2" [1]. The all-other-sessions clause matters for recovery: when a user enrolls a backup factor from a new device because their primary device is lost, the previous session on the lost device (if it still exists in any form) is severed.

The unenroll path is the inverse and carries an additional AAL constraint. From the docs verbatim: "A user has to have an aal2 authenticator level in order to unenroll a verified factor" [2]. The implication: the self-service "remove this factor" button cannot be reached at AAL1. Either the route is gated to AAL2 already (via proxy.ts route protection) or the click triggers a step-up challenge before the action runs. Skipping the step-up means the call returns an error the user reads as a broken button.

What goes wrong with BYO recovery codes?

The pull toward implementing application-layer recovery codes is real. The pattern feels familiar, the UI is well-understood, and "show ten codes after enrollment" maps cleanly to existing UX patterns. The implementation that ships in the first attempt almost always has a load-bearing security defect, and the defect is usually one of two shapes.

Shape one is plaintext storage. The natural first implementation is a text[] column on the profiles table, populated with cryptographically random 10-character strings at enroll time. The user sees them once, copies them, and the application holds a verbatim copy for the lookup later. CWE-256 names this directly: "Plaintext Storage of a Password" [8]. The reasoning extends to any bypass credential: a recovery code is a password equivalent for the purposes of the threat model, and any database backup, log capture, or compromised admin session that touches the row hands the attacker AAL2 bypass material for every user who has codes.

The fix is the same fix passwords need: hash each code with a slow KDF (bcrypt, scrypt, or argon2) and store the hash. On redemption, the user submits the code, the server iterates the stored hashes, matches one, marks it consumed (or deletes it), and proceeds. The straightforward server-side shape:

-- Schema for hashed application-layer recovery codes
create table recovery_codes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  code_hash text not null,
  consumed_at timestamptz,
  created_at timestamptz not null default now()
);

-- RLS: users can read their own consumed state, never the hashes.
-- Only service_role can insert / update / delete.
alter table recovery_codes enable row level security;

create policy "users read own consumed_at"
on recovery_codes for select
using ((select auth.uid()) = user_id);

Shape two is more subtle. Even with hashed storage, a recovery code can redeem the user back to AAL2 on a fresh session, which is exactly what the user wants when they have lost their device. But it can also redeem an attacker to AAL2 on the attacker's fresh session, provided the attacker has the password. Recovery codes are AAL1-redeemable bypass material; the password is also AAL1. Two AAL1 secrets, one AAL2 outcome. NIST SP 800-63B treats this as an assurance downgrade by design: "Recovery codes used to recover an account at which they are no longer able to authenticate" sit at AAL1 in the framework [6], and the resulting session must be treated as such until the user re-establishes a real second factor.

What this looks like in practice: a recovery-code redemption should not be a one-step "log me in fully" path. It should redeem the user to AAL1, then immediately walk them through enrolling a new TOTP factor, after which they reach AAL2. The recovery-code flow becomes a re-binding flow with one extra hop, not a backdoor AAL2.

Most BYO recovery code implementations skip the re-binding step and grant AAL2 directly on redemption. That choice turns the recovery surface into the cheapest way for an attacker holding a password + leaked recovery code to acquire AAL2. The Supabase-recommended path (backup TOTP factor) avoids this entirely: a backup TOTP is itself a second factor, so verifying it produces AAL2 the legitimate way, no re-binding hop required.

The shape of the decision the architectural review converges on: if you must ship application-layer recovery codes (regulatory, contractual, accessibility), hash them with a slow KDF, redeem them at AAL1, and force factor re-enrollment before AAL2. If you can defer them, defer them, and surface backup-TOTP-factor enrollment as the primary recovery prompt in the UI.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

How do you wire support-side factor reset with an audit trail?

Some accounts will lose access to every factor. The user's phone is in the river, the password manager backup is in the same iCloud account they cannot recover, and the magic-link email address has been replaced. This is the case the Supabase admin API exists for. auth.admin.mfa.deleteFactor(), called from a trusted server context with the service_role key, removes any factor on any user. Verbatim from the docs: "Deletes a factor on a user. This will log the user out of all active sessions if the deleted factor was verified" [3].

The session-invalidation behavior is load-bearing. Any active session that was issued at AAL2 on the deleted factor cannot continue to claim AAL2 once that factor is gone; the all-sessions logout closes the gap. The behavior is correct by default; the failure mode is procedural, not technical.

The procedural failure is that the admin API call leaves no audit trace by itself. There is no row in auth.audit_log_entries that says "support agent X deleted factor Y for user Z on date D, after the user uploaded government ID Q to ticket R." If three months later there is an incident review asking "did support help an attacker take this account," the answer the team gives has to come from somewhere outside Supabase. The shape the audit needs:

create table mfa_admin_actions (
  id uuid primary key default gen_random_uuid(),
  target_user_id uuid not null references auth.users(id) on delete restrict,
  acting_admin_user_id uuid not null references auth.users(id) on delete restrict,
  action text not null check (action in ('delete_factor', 'list_factors')),
  factor_id uuid,
  reason text not null,
  ticket_ref text,
  ip_address inet,
  user_agent text,
  acted_at timestamptz not null default now()
);

-- Service-role only. No regular-user policies; this table is forensic.
alter table mfa_admin_actions enable row level security;

The Server Action that supports the audit pattern uses the admin client with the validated acting admin identity drawn from the session:

'use server'

import { createServerClientWithCookies } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { headers } from 'next/headers'
import { z } from 'zod'

const deleteFactorSchema = z.object({
  targetUserId: z.string().uuid(),
  factorId: z.string().uuid(),
  reason: z.string().min(10).max(500),
  ticketRef: z.string().min(1).max(64),
})

export async function adminDeleteMfaFactor(formData: FormData) {
  const supabase = await createServerClientWithCookies()
  const { data: claims, error: claimsError } = await supabase.auth.getClaims()
  if (claimsError || !claims) return { error: 'Not authenticated' }
  if (claims.claims.aal !== 'aal2') return { error: 'AAL2 required', requireMfa: true }
  if (!claims.claims.user_role?.includes('support_admin')) {
    return { error: 'Insufficient role' }
  }

  const parsed = deleteFactorSchema.safeParse({
    targetUserId: formData.get('targetUserId'),
    factorId: formData.get('factorId'),
    reason: formData.get('reason'),
    ticketRef: formData.get('ticketRef'),
  })
  if (!parsed.success) return { error: parsed.error.errors[0].message }

  const admin = createAdminClient()
  const headerList = await headers()

  // Insert audit row BEFORE the destructive call. Fail closed if audit fails.
  const { error: auditError } = await admin.from('mfa_admin_actions').insert({
    target_user_id: parsed.data.targetUserId,
    acting_admin_user_id: claims.claims.sub,
    action: 'delete_factor',
    factor_id: parsed.data.factorId,
    reason: parsed.data.reason,
    ticket_ref: parsed.data.ticketRef,
    ip_address: headerList.get('x-forwarded-for') ?? null,
    user_agent: headerList.get('user-agent') ?? null,
  })
  if (auditError) return { error: 'Audit write failed; aborted' }

  const { error } = await admin.auth.admin.mfa.deleteFactor({
    userId: parsed.data.targetUserId,
    id: parsed.data.factorId,
  })
  if (error) return { error: error.message }

  return { success: 'Factor deleted; user signed out of all sessions' }
}

Four properties earn their place. The audit write happens before the destructive call: if the audit fails, the deletion is aborted, so there is never a delete with no record. The acting admin's identity is read from the validated getClaims() call, not from a form field, so a support agent cannot impersonate another agent in the audit row. The AAL2 check and the role check both run on the admin's own session, so an AAL1-only support session cannot trigger a factor reset even if the agent has the right role bits. The IP and user-agent are captured for the forensic case where "did anyone reuse this account" turns into a question after the fact.

The user who is helped by this action receives a notification email asynchronously. The email tells them their MFA factor was reset by support, names the ticket reference, and includes a "I did not request this" link that opens a security investigation. Most of the time the email is redundant (the user just asked for the reset). Occasionally it is the only signal the user has that something has gone wrong on the support side.

The auth.admin.mfa.listFactors() method exists for the read side of the support workflow [3]. It is what the support agent uses to see which factors exist on the account before deciding what to delete. The same audit table receives list_factors rows so the read access leaves a trace too; a support agent who can read factors can correlate that read with later attacker activity, and the audit row is where that correlation lands.

What are the 5 most common MFA recovery failure modes?

These five account for the overwhelming majority of "I lost my phone" support tickets that escalate into security incidents. They appear in roughly this frequency order across MFA recovery audits.

Failure 1: Shipping with no backup factor and counting on support to fix it

The single most common failure: the application surfaces MFA enrollment as a one-factor flow, the user enrolls a single TOTP, and the support queue absorbs every lost-device case from then on. CWE-308 names the broader pattern (single-factor authentication for high-risk resources) [7]; the recovery-channel variant is that single-factor recovery becomes the bottleneck for AAL2 restoration across the user base. The team builds a auth.admin.mfa.deleteFactor() flow that runs whenever support is satisfied, and the recovery surface is now "social engineer the support agent" instead of "compromise the TOTP secret."

The fix is structural. Make backup TOTP factor enrollment part of the same flow as the primary, not a separate "manage your security" page. Show the success state as "you have 2 factors" rather than "MFA is on." If the user dismisses the second-factor prompt, persist the prompt as a banner on the dashboard until they enroll. The Supabase API supports up to ten factors per user; the application should treat one factor as a degenerate intermediate state.

The same Supabase guidance applies to MFA on phone-based factors [4]. Phone factors have known interception risks at the carrier layer and should not be the only factor on an account; backup TOTP behind the phone factor is the standard architecture.

Failure 2: Application-layer recovery codes stored in plaintext

Covered in depth above. The natural first implementation is text[] on profiles. CWE-256 names it directly [8]: "Plaintext Storage of a Password" applies to any bypass credential, not just passwords as users understand the word. The data leak risk is the same as for passwords; the auth-bypass risk is strictly larger because each code is unconstrained by the rate-limiting that protects password attempts.

The fix is mechanical: hash each code with a slow KDF before storing. Use a table with one row per code, not an array column, so consumed codes can be deleted individually. RLS denies all reads of the hash column to the user the codes belong to; only service_role ever sees the hashes. The user's account-management page only sees consumed_at to render "codes remaining: N." The redemption call iterates hashes server-side and rejects more than one redemption attempt per minute per user via the rate limiter.

A second fix often missed: recovery-code redemption must redeem to AAL1, not AAL2. The redeemed session walks the user through enrolling a fresh TOTP factor before any AAL2 operation is allowed. This preserves the NIST 800-63B framing of recovery as an assurance event that requires re-binding, not a backdoor.

Failure 3: Factor mutations allowed indefinitely on a long-lived AAL2 session

The user authenticates and steps up to AAL2 at 9 AM on Monday. The session is configured for a long refresh window. On Friday afternoon, an attacker who has compromised the session (a phished refresh token, a malware-captured cookie set) calls the factor-management API, enrolls their own backup TOTP, and is now permanently AAL2-eligible on the account even if the original AAL2 factor is revoked.

The pillar's Failure 4 (AAL1↔AAL2 conflation on protected routes) covers a different shape: gating routes at AAL2. The shape here is that AAL2 alone is not enough for sensitive factor mutations; recent re-authentication is. NIST 800-63B has the concept of "reauthentication" for sensitive operations [6], and the standard implementation is that factor enrollment, factor deletion, and password change all require a fresh re-challenge of the current AAL2 factor within the last few minutes, not just a stale AAL2 claim.

The application-layer pattern:

'use server'

export async function rotateBackupFactor(formData: FormData) {
  const supabase = await createServerClientWithCookies()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims || claims.claims.aal !== 'aal2') {
    return { error: 'AAL2 required', requireMfa: true }
  }

  // Re-authentication window check: AAL2 within the last 5 minutes.
  const aalEstablishedAt = claims.claims.amr?.find(
    (e: { method: string; timestamp: number }) => e.method === 'totp'
  )?.timestamp
  const ageSeconds = aalEstablishedAt ? Date.now() / 1000 - aalEstablishedAt : Infinity

  if (ageSeconds > 300) {
    return { error: 'Re-authentication required', requireRecentMfa: true }
  }

  // Safe to mutate factors below this point.
  // ...
}

The amr claim (Authentication Methods References) carries a timestamp for each method used to reach the current AAL [1]. The five-minute window is the standard implementation; some products tighten this to 60 seconds for the highest-sensitivity operations. The point is that AAL2 is necessary but not sufficient: recent AAL2 is what gates factor management.

Failure 4: Support-staff factor reset with no audit trail

Covered in depth in the previous section. The recovery flow ships, the support agent helps the user, the deleteFactor call succeeds, the user gets back in, the next person who looks at the security log sees no record of any of it. Three months later, the incident review cannot answer "did support help an attacker take this account."

The fix: write the audit row before the destructive call, fail closed if the audit write fails. Capture the acting admin (from validated session), the target user, the factor ID, the reason, the ticket reference, the IP and user-agent. Notify the user asynchronously by email with a "I did not request this" link. This is OWASP A09 territory (Security Logging and Monitoring Failures); the architectural defense is that destructive admin operations on auth state are never invisible.

Failure 5: Trusted-device cookies that survive factor rotation

Some products implement "remember this device for 30 days" on top of AAL2 so users do not see the MFA challenge every day. The cookie is signed, scoped to the user, and marks the device as AAL2-trusted for some window. The shape works if the cookie is invalidated whenever the underlying factor changes; the shape fails when the cookie is allowed to outlive the factor.

The failure pattern: the user replaces their TOTP factor (after a lost-device support reset, or after rotating to a new authenticator app). The trusted-device cookie still carries an AAL2 claim bound to the previous factor's ID. The cookie's expiration is set for two more weeks. For two weeks, the previous factor is no longer required to reach AAL2 on that device; the cookie covers it. If the cookie was issued on an attacker-controlled device (because the attacker briefly had AAL2 before the factor reset), the attacker keeps AAL2 access for the remaining cookie lifetime.

The fix: bind the cookie to the factor ID, not just the user ID. When the factor changes (enroll, unenroll, or admin delete), invalidate all trusted-device cookies bound to old factor IDs. The structural pattern is a trusted_devices table:

create table trusted_devices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  factor_id uuid not null,
  device_fingerprint text not null,
  expires_at timestamptz not null,
  revoked_at timestamptz,
  created_at timestamptz not null default now()
);

create index on trusted_devices (user_id, factor_id) where revoked_at is null;

Whenever auth.admin.mfa.deleteFactor runs or auth.mfa.unenroll succeeds, an update trusted_devices set revoked_at = now() where user_id = $1 and factor_id = $2 runs in the same transaction. The middleware that reads the trusted-device cookie ignores any row with a non-null revoked_at.

The same revocation discipline applies when a user changes their password. The 1.1 supporting on password reset failure modes catalogues the broader session-fixation surface; trusted-device cookies are a special case of the same shape (a credential that survives a state change it should not).

How does this fit your overall auth architecture?

MFA recovery is the surface where the application's architectural commitments get tested. Every commitment from the broader auth posture shows up here. Identity from the validated session, not from a request parameter: that is what stops Failure 1's enrollment flow from being callable on behalf of someone else. AAL-aware route protection: that is what stops Failure 3 from letting a stale AAL2 claim mutate factors. Audit logs on admin-tier operations: that is what closes Failure 4. RLS that reads the JWT's aal claim: that is what stops Failure 5's trusted-device cookie from elevating access without a real factor behind it.

The four architectural commitments line up:

  • Backup TOTP enrollment is part of the primary flow, not a separate management page. Single-factor accounts are an intermediate state with a persistent prompt to upgrade. Failure 1.
  • Application-layer recovery codes, if shipped, are hashed and redeem at AAL1. The redemption walks the user through re-enrolling a fresh factor before any AAL2 operation runs. Failure 2.
  • Factor management requires AAL2 and recent re-authentication. The amr claim's timestamp is the gate, not the bare aal claim. Failure 3.
  • auth.admin.mfa.deleteFactor() writes an audit row before the destructive call. The audit row captures the acting admin from the validated session, the target user, the reason, and the ticket reference. The user is notified by email. Failure 4.
  • Trusted-device cookies are bound to factor IDs and revoked when the underlying factor changes. Failure 5.

For the broader auth architecture, the OAuth, magic links, and MFA pillar covers the rest of the surface: PKCE flow, redirect URL allowlists, AAL2 step-up patterns, RLS that reads the aal claim, and the 5 enrollment-time failure modes that pair with these 5 recovery-time failure modes. The JWT and session management pillar covers the getClaims() validation and the amr claim that Failure 3's reauthentication check depends on. The multi-tenancy and RBAC pillar covers the Custom Access Token Hook pattern that injects the user_role claim Failure 4's support-admin check reads.

For tooling: the JWT decoder parses any Supabase access token and shows the aal, amr, and factor list claims live, which is the fastest way to verify whether your Server Action is reading the AAL state the way the JWT actually carries it. The SaaS Security Checklist covers the broader pre-launch posture; the auth-and-session items there cover the surfaces this post focuses on.

If your codebase started as an AI-generated prototype, the MFA recovery surface is one of the first places to audit. The patterns AI tools generate for MFA usually skip the backup-factor prompt, store recovery codes as plain text[], and never write to an audit table when support resets a factor. The vibe-coded migration playbook covers the broader audit-and-harden sequence; the auth-failure category from the OWASP Top 10 pillar maps each failure mode above to the OWASP 2025 A07 line item, and Failure 4 maps to A09 as well.

SecureStartKit ships the auth surface this post describes as defaults: Server Actions for every auth mutation, getClaims() validation on every protected operation, AAL-aware RLS policies on sensitive tables, and the audit-table pattern for admin operations on auth state. The MFA enrollment UX is built around the backup-factor-at-enroll-time recommendation rather than the optional-second-factor pattern that ships in most templates. The five recovery failure modes above are the audit; the fixes ship in a handful of lines once the architecture is in place.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Multi-Factor Authentication, Supabase Auth Documentation— supabase.com
  2. auth.mfa.unenroll(), Supabase JavaScript Reference— supabase.com
  3. auth.admin.mfa.deleteFactor(), Supabase JavaScript Reference— supabase.com
  4. MFA (TOTP), Supabase Auth Documentation— supabase.com
  5. Multi-factor Authentication via Row Level Security Enforcement, Supabase Blog— supabase.com
  6. NIST SP 800-63B, Digital Identity Guidelines: Authentication and Authenticator Management— pages.nist.gov
  7. CWE-308: Use of Single-Factor Authentication— cwe.mitre.org
  8. CWE-256: Plaintext Storage of a Password— cwe.mitre.org
  9. OWASP Top 10:2025: A07 Identification and Authentication Failures— owasp.org

Related Posts

May 21, 2026·Security

Supabase OAuth, Magic Links, MFA in Next.js [2026]

Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.

May 17, 2026·Security

Supabase JWT + Session Management in Next.js [2026]

Supabase JWT lifecycle, ES256 asymmetric signing keys, httpOnly cookie storage, and getClaims vs getUser vs getSession for Next.js apps.

Jun 1, 2026·Security

Supabase Storage Multi-Tenant RLS: 5 Leak Modes [2026]

Supabase Storage multi-tenant isolation: path-encoded RLS with tenant_id JWT claim, bucket-vs-path decision, and 5 cross-tenant leak modes.