MFA + step-up
TOTP for owners
Any user holding owner in any workspace is required to enrol TOTP before performing a sensitive action. Enforcement happens at two layers:
- App-layer gate.
requireOwnerMfa(userId, reason, next)in/src/lib/mfa/gate.tsruns at the top of the gated server action. If the caller is owner anywhere and has no verified TOTP factor, the action 302s to/app/settings/mfa?reason=…&next=…instead of executing. The gate usessupabase.auth.admin.getUserByIdto readuser.factors[]via the service-role client — theauthschema isn't exposed via PostgREST, so the admin API is the only path that doesn't require a custom RPC. - DB-side helper.
app_private.user_has_mfa(uuid)is the same check expressed in SQL — used by future policies that need to gate row visibility on MFA posture. Today, no policy uses it; it's available for forks that want to push gating down to RLS.
Gated actions
Every action below calls requireOwnerMfa after the ownership/role check and before any state mutation:
- Billing changes —
startCheckoutandopenCustomerPortalin/src/lib/billing/actions.ts. - Role changes —
changeMemberRolein/src/lib/members/actions.ts. - Member removal —
removeMemberin the same file. - Impersonation start —
startImpersonationin/src/lib/impersonation/actions.ts. (Impersonation additionally requires a 6-digit OTP step-up, see below.) - Workspace deletion — wired the same way once the soft-delete action ships.
The enrolment UI
Live at /app/settings/mfa. The flow:
- Click Enrol TOTP. The server calls
supabase.auth.mfa.enrolland stores the returned QR + base32 secret in an HttpOnly cookie scoped to the settings path with a 15-minute TTL. Supabase returns the secret exactly once, so the cookie is the only place it can be re-shown across a page refresh. - Scan the QR or paste the secret into 1Password, Authy, Google Authenticator, or any RFC-6238 app.
- Enter the 6-digit code; the server calls
mfa.challenge+mfa.verify. On success the factor flips toverifiedanduser_has_mfa(auth.uid())returns true. The cookie is cleared; an audit eventmfa.enrolledfires against every workspace the user owns. - Cancelling unenrols the pending factor (Supabase's
mfa.unenroll) and clears the cookie so a clean re-enrol is possible.
Removing a verified factor refuses with owner_must_keep_mfaif it would leave an owner with zero verified factors — the same invariant the runtime gate enforces, surfaced earlier so the user doesn't lock themselves out of their own billing change.
Impersonation step-up
Impersonation has a second factor on top of the MFA gate: a 6-digit OTP delivered to the admin's email (surfaced inline in dev mode). On verify, the server mints a 60-minute JWT signed with SUPABASE_JWT_SECRET that becomes the new effective session. See Audit log + impersonation for the JWT shape and the doubly-logged audit pattern.
AAL2
Supabase Auth's AAL2 (session-level MFA flag) is the freshness signal — a recently-verified session can be expected to retain a higher trust level. The load-bearing gate is the server-side requireOwnerMfa check, because AAL2 alone is sidestepped by any path that lets a magic-link user act without ever enrolling. The two work together: enrolment proves possession, AAL2 proves recency.
Non-owners
TOTP enrolment is available to every user at /app/settings/mfa, but the gate only triggers for users who hold ownerin at least one workspace. Admins, members, and guests can enrol if they want defence in depth; the demo doesn't require it.