Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Multi-Factor Authentication

Multi-factor authentication (MFA) adds a second factor beyond a primary credential (password, magic link, social login). Aero2 ships TOTP with recovery codes, WebAuthn/Passkeys, and an optional "remember this device" skip. SMS OTP is planned.

Supported Methods

TOTP (Authenticator Apps)

Time-based One-Time Passwords per RFC 6238. Compatible with:

  • Google Authenticator
  • Authy
  • 1Password
  • Microsoft Authenticator

The shared secret is encrypted at rest with AES-256-GCM. On enrollment, Aero2 generates 10 single-use recovery codes (SHA-256 hashed) that the user should store securely as a fallback if they lose their authenticator device. Recovery codes can be regenerated at any time (requires re-verification with a fresh TOTP code).

WebAuthn / Passkeys

FIDO2 passkeys for phishing-resistant, passwordless sign-in. A passkey requires both possession (the authenticator: device, security key, or password manager) and a verifier (PIN or biometric), so it counts as two-factor authentication on its own — passkey login skips the secondary MFA challenge.

Public keys are stored in webauthn_credentials (one row per credential, scoped to (app_id, user_id)); the user's private key never leaves the authenticator. The Relying Party ID is derived from the request hostname, so a passkey registered on taskflow.aero2.dev cannot be used on other-app.aero2.dev. Registration and assertion both require user verification — authenticators that only confirm presence are rejected.

Users can register multiple passkeys (e.g. a phone + a hardware key) from the dashboard's Profile → Passkeys section, and the same registration UI is mounted on /mfa-setup so users under mfa_policy=required can satisfy enrollment with a passkey instead of TOTP.

SMS OTP

:::info Planned Not yet implemented. See roadmap §12. :::

Per-Application MFA Policy

Each application configures its own MFA policy via PUT /api/applications/settings:

PolicyBehavior
off (default)MFA is unavailable; setup endpoints return 403
optionalUsers may enable MFA on their own profile
requiredAll users must enroll in MFA before they can access the app

When the policy is required, users without MFA receive a restricted mfa_setup_required session on login that only permits MFA enrollment. The session auto-upgrades to a full session once setup completes. Users cannot disable MFA while the policy is required.

See Configure MFA for an app for setup instructions.

What satisfies required?

Primary auth methodSatisfies required?
Email code + verified TOTP
Email code + verified passkey
Passkey sign-in (inherently 2FA)
Social login (OAuth/OIDC) + verified TOTP
Social login (OAuth/OIDC) + verified passkey
Social login alone (no local MFA enrolled)
Email code alone

A few deliberate choices behind this matrix:

  • Passkeys count as MFA on their own. A passkey requires both possession (the authenticator) and a verifier (PIN or biometric), so it is inherently 2FA. Users authenticated via passkey skip the secondary MFA challenge entirely.
  • IdP-side MFA does not satisfy local MFA. Aero2 cannot verify whether a social IdP enforced MFA during a given login, and the IdP's policy can change without notice. Social-login users must still enroll a local factor (TOTP or passkey) when the policy is required.
  • Recovery codes are not a primary factor. They are a fallback for an enrolled MFA method, not a standalone way to satisfy the policy.

Remember This Device

When mfa_remember_device_days > 0 (default 30), users can opt to skip the MFA challenge on subsequent logins from the same device. Aero2 sets a signed device-trust cookie scoped to the app, and login flows verify the cookie against the mfa_trusted_devices table before issuing a challenge. Set mfa_remember_device_days to 0 to disable the feature entirely.

Login Challenge Flow

Primary credential verified

The user signs in via email code, password, or social login as normal.

Challenge token issued

If mfa_enabled = true for the user and the app's mfa_policy is not off, Aero2 returns a short-lived (5 min) mfa_token instead of a session. Browser clients receive a cookie + redirect to /login?mfa_required=true; API clients receive { mfa_required: true, mfa_token } JSON.

User submits second factor

The user posts their 6-digit TOTP code to POST /api/auth/mfa/verify (or a recovery code to POST /api/auth/mfa/recovery). If the device is trusted, this step is skipped.

Full session created

On success, Aero2 creates the full session and the user is signed in. Recovery code use returns recovery_codes_remaining so the UI can warn when codes are running low.

Enabling TOTP (User Flow)

The user-facing MfaSettings component in the Aero2 dashboard wraps these endpoints, but they are also documented for direct integration:

# 1. Start setup — returns secret + otpauth URI for QR rendering
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/totp/setup \
  -H "Cookie: <session>"
 
# 2. Verify with a 6-digit code from the authenticator
#    Returns 10 recovery codes (shown once)
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/totp/verify \
  -H "Cookie: <session>" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
 
# 3. (Later) Regenerate recovery codes — requires fresh TOTP code
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/recovery-codes/regenerate \
  -H "Cookie: <session>" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
 
# 4. Disable MFA — accepts a TOTP code OR a recovery code
#    Blocked (403) when app mfa_policy is "required"
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/totp/disable \
  -H "Cookie: <session>" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'

Enabling and Using Passkeys (User Flow)

Passkeys require auth_methods to include "passkey" and mfa_policy to be optional or required for the application. The dashboard renders the controls under Profile → Passkeys; the underlying endpoints are documented for direct integration.

# 1. Start enrollment — returns PublicKeyCredentialCreationOptions JSON
#    The browser then calls navigator.credentials.create() with these options.
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/webauthn/register-options \
  -H "Cookie: <session>"
 
# 2. Finish enrollment — the browser posts the attestation back here.
#    Stores the public key and (atomically) flips users.mfa_enabled to true.
curl -X POST https://<app>.aero2.dev/api/users/me/mfa/webauthn/register \
  -H "Cookie: <session>" \
  -H "Content-Type: application/json" \
  -d '{"name": "MacBook Touch ID", "response": { "...": "AttestationResponseJSON" }}'
 
# 3. List registered passkeys
curl https://<app>.aero2.dev/api/users/me/mfa/webauthn \
  -H "Cookie: <session>"
 
# 4. Remove a passkey by row id
#    Last-factor lockout: when mfa_policy=required, the request is blocked (403)
#    if removing this credential would leave the user with zero verified factors.
curl -X DELETE https://<app>.aero2.dev/api/users/me/mfa/webauthn/<row-id> \
  -H "Cookie: <session>"

Sign-in with a passkey

The login page shows a Sign in with a passkey button when the app enables passkey in auth_methods and the browser supports WebAuthn. The frontend uses @simplewebauthn/browser to drive the assertion ceremony; the server-side endpoints below are public (no session required):

# 1. Request assertion options. Omit `email` to use the discoverable-credential
#    flow (the browser shows every passkey scoped to this RP). Pass an email to
#    narrow `allowCredentials` to that user's registered passkeys.
curl -X POST https://<app>.aero2.dev/api/auth/webauthn/login-options \
  -H "Content-Type: application/json" \
  -d '{}'
 
# 2. Submit the assertion produced by navigator.credentials.get().
#    On success the response sets the access_token cookie and returns the JWT.
#    Passkey login bypasses the MFA challenge — it is inherently 2FA.
curl -X POST https://<app>.aero2.dev/api/auth/webauthn/login \
  -H "Content-Type: application/json" \
  -d '{"response": { "...": "AuthenticationResponseJSON" }}'

Pending users (those whose membership is awaiting approval) receive { membership_pending: true } instead of a session, mirroring email-code login.

Audit Events

The following events are written to the audit log:

EventWhen
mfa_enabledUser completes TOTP verification
mfa_disabledUser disables MFA, or removes their last verified factor
mfa_recovery_usedLogin completed via recovery code
passkey_registeredUser adds a new WebAuthn credential
passkey_removedUser removes a WebAuthn credential
loginAny successful login (includes method field — email_code, passkey, …)
login_failedFailed login attempt (includes reason field)

Recovery code regeneration is also audited.