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:
| Policy | Behavior |
|---|---|
off (default) | MFA is unavailable; setup endpoints return 403 |
optional | Users may enable MFA on their own profile |
required | All 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 method | Satisfies 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:
| Event | When |
|---|---|
mfa_enabled | User completes TOTP verification |
mfa_disabled | User disables MFA, or removes their last verified factor |
mfa_recovery_used | Login completed via recovery code |
passkey_registered | User adds a new WebAuthn credential |
passkey_removed | User removes a WebAuthn credential |
login | Any successful login (includes method field — email_code, passkey, …) |
login_failed | Failed login attempt (includes reason field) |
Recovery code regeneration is also audited.