Design: Multi-Factor Authentication (Phase 2)
This document describes the design for MFA support in Aero2, covering TOTP, WebAuthn/Passkeys, and SMS OTP.
Overview
MFA is configurable per-application (off | optional | required) via app settings. The implementation starts with TOTP (authenticator apps), then adds WebAuthn/Passkeys for phishing-resistant auth, and SMS OTP as a fallback.
Schema
MFA Credentials
CREATE TABLE user_mfa (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES applications(id),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('totp', 'webauthn', 'sms')),
secret TEXT, -- Encrypted TOTP secret (AES-256-GCM)
credential_data TEXT, -- JSON for WebAuthn public key, credential ID, sign count
verified BOOLEAN NOT NULL DEFAULT 0,
name TEXT, -- User-provided label (e.g., "My YubiKey")
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, type) -- One TOTP per user; multiple WebAuthn via separate design
);Recovery Codes
CREATE TABLE mfa_recovery_codes (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES applications(id),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL, -- Hashed recovery code
used_at DATETIME,
created_at DATETIME DEFAULT (datetime('now'))
);User Table Addition
ALTER TABLE users ADD COLUMN mfa_enabled BOOLEAN NOT NULL DEFAULT 0;TOTP Implementation
Setup Flow
- User calls
POST /api/users/me/mfa/totp/setup - Server generates 20-byte random secret (base32 encoded)
- Secret stored encrypted in
user_mfawithverified = false - Returns
{ secret, otpauth_uri, qr_code_data_url }
Verification
- User enters 6-digit code from authenticator app
- Server calls
POST /api/users/me/mfa/totp/verifywith the code - TOTP verified using RFC 6238 (30-second step, ±1 window for clock skew)
- On success:
user_mfa.verified = true,users.mfa_enabled = true - Generate 10 recovery codes, hash and store
- Return recovery codes (shown once)
TOTP Generation (RFC 6238)
// src/backend/totp.ts
function generateSecret(): string; // 20-byte random, base32
function generateTOTP(secret: string, time?: number): string; // 6-digit code
function verifyTOTP(secret: string, code: string, window?: number): boolean;
function generateOTPAuthURI(secret: string, email: string, issuer: string): string;Uses Web Crypto API for HMAC-SHA1 computation per the RFC.
Login Flow with MFA
User submits credentials (password or OAuth callback)
│
├─ MFA not enabled → Create session, return cookie
│
└─ MFA enabled → Issue short-lived mfa_token (JWT, 5-min expiry)
│ Return { mfa_required: true, mfa_token, mfa_methods: ["totp"] }
│
└─ User submits TOTP code
│
├─ POST /api/auth/mfa/verify { mfa_token, code }
│ Verify TOTP → Create full session → Return cookie
│
└─ POST /api/auth/mfa/recovery { mfa_token, recovery_code }
Verify recovery code → Mark used → Create session
Warn about remaining recovery codesThe mfa_token JWT contains:
sub— user IDtoken_use—"mfa_challenge"exp— 5 minutes from nowapp_id— application context
WebAuthn / Passkeys
Registration
POST /api/users/me/mfa/webauthn/register-options— generatePublicKeyCredentialCreationOptions- RP ID = app subdomain
- Supported algorithms: ES256, RS256
- Challenge stored with 5-min TTL
- Browser WebAuthn API creates credential
POST /api/users/me/mfa/webauthn/register— verify attestation, store public key
Login
POST /api/auth/webauthn/login-options— generatePublicKeyCredentialRequestOptions- Browser WebAuthn API asserts credential
POST /api/auth/webauthn/login— verify assertion, create session- Passkey login bypasses additional MFA (inherently 2FA)
Multiple Credentials
Allow multiple WebAuthn credentials per user (the UNIQUE(user_id, type) constraint is relaxed for WebAuthn entries).
SMS OTP
Phone Number Management
CREATE TABLE user_phone_numbers (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES applications(id),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
phone_verified BOOLEAN NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now'))
);SMS Provider Abstraction
// src/backend/sms.ts
interface SMSSender {
sendSMS(to: string, message: string): Promise<void>;
}Configurable via SMS_PROVIDER env var (twilio | vonage | mock).
SMS MFA Flow
- After primary authentication, if SMS is the MFA method
- Send 6-digit code to user's verified phone number
- Code expires in 5 minutes
- Rate limit: 3 SMS per phone per 15 minutes
Per-App MFA Policy
Stored in applications.settings:
{
"mfa_policy": "optional" // "off" | "optional" | "required"
}- off — MFA not available for this app
- optional — Users can choose to enable MFA
- required — Users must set up MFA on next login if not already enabled
When required and mfa_enabled = false, the login flow forces MFA setup before granting a session.
Recovery Codes
- 10 codes generated on MFA setup
- Each code: 10-character alphanumeric string
- Stored as hashed values (HMAC-SHA256)
- Each code is single-use
- Regeneration available (replaces all existing codes)
- Shown once at setup with download/copy option
API Endpoints Summary
| Method | Endpoint | Description |
|---|---|---|
POST | /api/users/me/mfa/totp/setup | Start TOTP setup |
POST | /api/users/me/mfa/totp/verify | Confirm TOTP setup |
POST | /api/users/me/mfa/totp/disable | Disable TOTP |
POST | /api/users/me/mfa/webauthn/register-options | WebAuthn registration options |
POST | /api/users/me/mfa/webauthn/register | Complete WebAuthn registration |
DELETE | /api/users/me/mfa/webauthn/:credentialId | Remove passkey |
POST | /api/auth/mfa/verify | Verify MFA during login |
POST | /api/auth/mfa/recovery | Use recovery code |
POST | /api/auth/webauthn/login-options | WebAuthn login options |
POST | /api/auth/webauthn/login | Complete WebAuthn login |
Audit Events
mfa_enabled— MFA setup completedmfa_disabled— MFA removedmfa_verified— Successful MFA verification during loginmfa_recovery_used— Recovery code usedpasskey_registered— WebAuthn credential addedpasskey_removed— WebAuthn credential removedpasskey_login— Login via passkey
Dependencies
- Multi-tenancy (Phase 0) — all tables require
app_id - Email/password auth (Phase 1 #6) — MFA augments the password login flow
- Account lockout (#4) — failed MFA attempts should contribute to lockout