Site

Four related controls ship together: a second factor, passwordless passkeys, a step-up gate in front of sensitive actions, and a real device list your users can revoke from.

What is on by default

FeatureFlagDefault
TOTP two-factor + recovery codesMFA_ENABLEDon
Step-up re-authentication— (always active)on
Device list and remote revocation— (always active)on
Organization MFA policy— (per workspace, opt-in by an owner)off
Passwordless passkeysNEXT_PUBLIC_PASSKEYS_ENABLEDoff

Passkeys ship off because the Supabase passkey API is still experimental upstream. Everything else is ready to use.

Passwordless passkeys also require AUTH_SESSION_FINALIZATION_MODE=enforce and an AUTH_SESSION_FINALIZATION_SECRET of at least 32 characters. Startup fails when the public passkey flag is enabled without that server boundary. This keeps consent, pre-launch access, and session finalization authoritative even if a modified browser bypasses the application's passkey button.

Supabase configuration

The application flags only decide what your app offers. Supabase must also allow the factor, or enrollment fails at the provider.

Hosted project: Authentication → Multi-Factor. Enable TOTP, and WebAuthn if you want security keys as a second factor.

Local (supabase/config.toml):

toml
[auth.mfa]
max_enrolled_factors = 5

[auth.mfa.totp]
enroll_enabled = true
verify_enabled = true

[auth.mfa.web_authn]
enroll_enabled = true
verify_enabled = true

For passkeys you additionally need:

toml
[auth.passkey]
enabled = true

[auth.webauthn]
rp_id = "example.com"
rp_origins = ["https://example.com"]

rp_id must be the registrable domain — no scheme, no port. rp_origins must list every exact origin that runs a ceremony. Getting either wrong fails in the browser at ceremony time, not at startup, so verify it before you enable the flag.

Two-factor authentication

Users enroll an authenticator app from My account → Two-factor authentication. On the first verified factor they receive ten single-use recovery codes, shown exactly once.

Verifying that first factor opts the user into MFA enforcement. Every later sign-in starts at aal1 — including Google or another OAuth provider, a magic link, and a typed email OTP — and is redirected straight to the /mfa challenge before its requested destination, including checkout. Completing the challenge promotes the session to aal2 and returns the user to that destination.

The account page is not a bypass: an enrolled aal1 user is challenged before reaching My account. It remains reachable without aal2 only when the user has no verified factor and must enroll because of an organization policy.

Codes are 16 characters in XXXX-XXXX-XXXX-XXXX groups, drawn from an alphabet that excludes I, L, O and U. That is not cosmetic: it means a user who reads 0 from a printed sheet and types O still gets in, without any risk of colliding with a real code. Only a SHA-256 hash of the normalized code is ever stored.

Server identity boundary

The MFA APIs require an authenticated session, its finalization marker when configured, and an active application identity. Native factors and challenges remain tied to the Supabase Auth subject; recovery-code storage and audit attribution use the canonical appUserId. The server derives both coordinates: clients cannot choose either identity. Factor listing, TOTP enrollment, verification, removal, and recovery reset pass through one bounded Auth-provider contract. Supabase Auth and Better Auth keep their native factor formats behind their selected adapters. Organization MFA policy and passkeys remain separate capabilities whose composition-specific local evidence must be completed before the corresponding composition is certified.

Starting enrollment is a credential change and uses the same step-up dialog as factor removal. Existing factors must be verified first. A first factor remains possible through the recent-sign-in fallback when there is no factor to challenge.

All MFA handler responses are private and non-cacheable, including refusals. Missing application identity returns a generic 503 before handler parsing or effects. Malformed JSON returns 400; malformed, unknown and used recovery codes share MFA_RECOVERY_INVALID. Unreadable factor lists or malformed database responses fail instead of being treated as an empty factor list or successful recovery. Provider operations have a ten-second deadline and are never retried. A timeout during enrollment, verification, removal, or multi-factor recovery reset can be ambiguous; refresh factor state before attempting another mutation.

Recovery codes are break-glass, not a bypass

Using a recovery code removes every enrolled factor and signs the user out of their other devices. It does not grant a two-factor session — Supabase issues the assurance level, so nothing the application stores can fake one.

This is the honest behaviour for a lost-authenticator flow: the user gets back in, their old factor is gone, and they set two-factor up again. Every consumption sends an out-of-band email and writes an audit row.

Passkeys

Once enabled, users add passkeys from My account → Passkeys and sign in with the passkey button on /login.

The button is on the sign-in page only. On the registration page no account exists yet, so a passkey ceremony can only fail.

Registration and sign-in ceremonies run in the browser, because navigator.credentials exists nowhere else. They use the configured Auth-provider boundary and return only a success receipt or passkey ID to the interface; session data, user objects, tokens, and provider errors do not cross that boundary. Supabase Auth and Better Auth each provide an executable adapter.

Sign-in completes /api/auth/passkeys/finalize before it navigates. If that proof fails, the browser attempts to clear the locally issued session and shows only the generic localized error. Registration reports success only after the server confirms that the native credential belongs to the authenticated subject and records the application audit. The list refreshes after either outcome, because the provider may have committed a credential before a network or audit response failed. Ceremonies are never retried automatically and have no short timeout around the user's browser prompt; closing the page cancels them where the pinned SDK supports cancellation.

Management APIs require an active application actor and the configured session finalization checks. Native list, rename and deletion use a bounded Auth-provider contract with the configured issuer and provider subject; registration/removal audits and security alerts use canonical appUserId. The Supabase Auth and Better Auth adapters keep their native credential formats behind this port. Registration evidence is checked against that subject's native credential list before any audit or alert. Rename and removal also verify native ownership.

All four management handlers return private/non-cacheable responses, reject missing actors before effects, and return 400 for malformed JSON or credential IDs. An invalid native list fails with a generic 500 rather than a false empty list; provider errors are not exposed in the HTTP response or incident log. Each provider operation has a ten-second deadline that aborts the real request and body, and ambiguous rename/delete failures are never retried. Deletion still requires credential-change step-up. Browser ceremonies use their separate provider-neutral client boundary, while the sign-in finalization endpoint stays the server authority.

Step-up re-authentication

Sensitive actions require a fresh factor verification, not merely a valid session. Which actions, and how strict, is configuration:

ts
// config/app.ts
security: {
  stepUp: {
    factorMaxAgeMinutes: 5,
    actions: {
      adminEscalation:  'factor-or-recent-auth',
      billingMutation:  'factor-or-recent-auth',
      credentialChange: 'factor',
      destructive:      'factor',
    },
  },
}

A factor policy demands a factor verified in the last five minutes; a recent sign-in does not count. factor-or-recent-auth accepts either.

Users with no factor enrolled are not locked out: the gate falls back to the recent-sign-in check, so a single-factor account can still manage its own settings. The exception is a user whose organization requires MFA and whose grace period has expired — they are told to enroll.

When a gated request is rejected the UI opens a dialog, collects the code, and retries the original request once. That is safe because the first attempt was rejected before anything happened.

Organization MFA policy

A workspace owner or admin can require two-factor for every member, from Organization → Settings → Authentication policy.

An enforced policy must permit an enrollment method implemented by both Auth adapters: currently TOTP. Passwordless passkeys are separate sign-in credentials; registering one does not enroll a second factor. Writes reject a required policy that allows only phone or WebAuthn factors. Existing unsupported policies remain enforced; an authorized operator must correct the stored policy to admit TOTP before members without a compliant factor can regain access. Do not bypass the session MFA guard to work around such a policy.

Use Check MFA coverage to load the number of members with two-factor set up. After coverage loads, the card warns how many members would be affected before enabling enforcement. Coverage is an informational snapshot, never an authorization decision. Loading settings does not contact the Auth provider for every member.

Policy reads distinguish a genuinely unconfigured workspace from a database failure. An unavailable policy displays the existing retry error screen instead of editable defaults. Unavailable coverage displays its own retry message while preserving the real policy and settings form. Coverage uses cursor pages, bounded concurrency and a five-second total deadline propagated to database and Auth I/O; it never presents a partial count as complete. The GET/PUT policy API requires the canonical application actor for membership checks and audit attribution; PUT retains adminEscalation step-up. RLS and MFA coverage use the application actor set on the shared Prisma transaction; provider-native factors and assurance metadata remain confined to the selected Auth adapter.

Members get a grace period (default 168 hours / 7 days) to enroll, counted from whichever came later: turning the policy on, or the member joining. Inside the window they can keep working. After it, they are routed to enrollment until they comply.

Enforcement happens server-side on every protected request. There is deliberately no client-side cache of "this user is compliant" — a cookie saying so could simply be sent by an attacker.

Devices and remote revocation

My account → Active devices lists real sessions: device, browser, OS, IP, when it was last active, and whether it completed two-factor. Users can sign out any device other than the one they are using.

The server routes send the verified native Auth subject to the configured live-session provider port and keep the canonical application actor for audit rows. Both one-device and all-other-device revocation require destructive step-up. The Supabase adapter validates at most 50 native rows and uses the stable PostgreSQL status for the current-session refusal; provider messages are never interpreted or returned. A ten-second deadline aborts the underlying cookie-backed request without retrying an ambiguous revocation.

One caveat worth understanding

Revoking a device invalidates its refresh token immediately, so every part of the application denies it at once. However, an access token that was already issued stays cryptographically valid until it expires — by default one hour.

To shrink that window, lower the JWT expiry in your Supabase project (Authentication → Sessions, or jwt_expiry in config.toml). 900 seconds is a reasonable hardened value; the trade-off is more frequent token refreshes.

toml
[auth]
jwt_expiry = 900

The application reads assurance claims (aal, amr) from the access token on every gated request. With asymmetric signing keys enabled on your Supabase project, that verification happens locally. With a legacy shared secret it costs a network round trip — still correct, just slower.

Enable them under Authentication → JWT Keys.

Security notification emails

Factor changes trigger a best-effort email in the user's locale: two-factor enabled, an authenticator removed, a recovery code used, or recovery codes replaced. A delivery failure does not undo the completed security action; the helper records a synthetic incident attributed to the canonical application actor, without provider error text.

The message says what kind of change happened and when — never a code, a factor id, or a device. Its purpose is that somebody who did not make the change learns about it on a channel the attacker does not control.