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.ts runs 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 uses supabase.auth.admin.getUserById to read user.factors[] via the service-role client — the authschema 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 changesstartCheckout and openCustomerPortal in /src/lib/billing/actions.ts.
  • Role changeschangeMemberRole in /src/lib/members/actions.ts.
  • Member removalremoveMember in the same file.
  • Impersonation start startImpersonation in /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:

  1. Click Enrol TOTP. The server calls supabase.auth.mfa.enroll and 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.
  2. Scan the QR or paste the secret into 1Password, Authy, Google Authenticator, or any RFC-6238 app.
  3. Enter the 6-digit code; the server calls mfa.challenge + mfa.verify. On success the factor flips to verified and user_has_mfa(auth.uid()) returns true. The cookie is cleared; an audit event mfa.enrolled fires against every workspace the user owns.
  4. 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.