Two-Factor Authentication (TOTP)
RFC 6238 time-based one-time password. SHA-1, 6 digits, 30s step.
Files
- Schema:
src/lib/db/schema/two-factor.ts—two_factor_secretstable. - Helpers:
src/lib/auth/totp.ts— secret generation, code verification, backup codes, otpauth URI. - Setup:
POST /api/internal/two-factor/setup. - Verify:
POST /api/internal/two-factor/verify. - Disable:
POST /api/internal/two-factor/disable. - Login integration:
src/lib/auth/options.ts— credentials provider checks TOTP when user has 2FA enabled.
Setup flow
- User clicks Enable 2FA in settings (UI not yet wired — direct API works).
POST /api/internal/two-factor/setup— server generates base32 secret, persists row withenabled: false, returns:{ "secret": "JBSWY3DPEHPK3PXP...", "otpauth_uri": "otpauth://totp/Sendoka:user@example.com?secret=...&issuer=Sendoka&digits=6&period=30" }- Client shows QR code from
otpauth_uri(use any QR lib —qrcode, etc.). - User scans into authenticator (Google Authenticator, 1Password, Authy, ...).
- User enters first 6-digit code →
POST /api/internal/two-factor/verify { code }. - Server
verifyTotp()— if valid, setsenabled: true,confirmed_at: now(), generates 8 backup codes (10 hex chars each), returns them. Show these once — never shown again.
Login flow
authorize() in src/lib/auth/options.ts:
- Validate email + password (existing flow).
- Look up
two_factor_secrets. Ifenabled: falseor missing, log the user in. - If enabled:
- No
credentials.totppresent →throw new Error("2FA_REQUIRED")— client should re-prompt. credentials.totpmatches one ofbackup_codes→ log in + remove used backup code.credentials.totppassesverifyTotp()→ log in.- Otherwise →
throw new Error("Invalid 2FA code").
- No
Front-end: pass totp as a third credential field to signIn("credentials", { email, password, totp }).
Verify window
verifyTotp(secret, token, window = 1) accepts codes within ±1 × 30s step. Tolerates mild clock drift. Rejects non-6-digit input.
Backup codes
- 8 codes, 10 hex chars each.
- Stored as a
text[]column ontwo_factor_secrets. - Each code is single-use — used codes are filtered out of the array on successful login.
- Plain text at rest (trade-off for simplicity — consider hashing if this goes to prod).
Disable
POST /api/internal/two-factor/disable { password } — requires the current password to avoid session hijack re-enabling.
Limitations
- No recovery email flow — if user loses authenticator AND backup codes, admin must delete
two_factor_secretsrow manually. - SMS-based 2FA not supported.
- No WebAuthn / passkeys yet.