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.

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.

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. The server records the result for the audit trail; it grants nothing on its own.

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.

The card shows how many members already have two-factor set up, and warns you how many would be affected before you flip the switch.

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.

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

Every factor change sends the account owner an email in their locale: two-factor enabled, an authenticator removed, a recovery code used, or recovery codes replaced.

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.