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_secrets table.
  • 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

  1. User clicks Enable 2FA in settings (UI not yet wired — direct API works).
  2. POST /api/internal/two-factor/setup — server generates base32 secret, persists row with enabled: false, returns:
    {
      "secret": "JBSWY3DPEHPK3PXP...",
      "otpauth_uri": "otpauth://totp/Sendoka:user@example.com?secret=...&issuer=Sendoka&digits=6&period=30"
    }
    
  3. Client shows QR code from otpauth_uri (use any QR lib — qrcode, etc.).
  4. User scans into authenticator (Google Authenticator, 1Password, Authy, ...).
  5. User enters first 6-digit code → POST /api/internal/two-factor/verify { code }.
  6. Server verifyTotp() — if valid, sets enabled: true, confirmed_at: now(), generates 8 backup codes (10 hex chars each), returns them. Show these once — never shown again.

An account with no password (SSO / OAuth) must also send email_code, a code mailed by POST /api/internal/user/deletion-code { "for": "two_factor_setup" }, or verify answers 403 EMAIL_CODE_REQUIRED / EMAIL_CODE_INVALID. For such an account a TOTP alone confirms org deletion and owner grants, so enrolling one has to prove the inbox, not just the session. Password accounts are unaffected. See security.md.

Login flow

authorize() in src/lib/auth/options.ts:

  1. Validate email + password (existing flow).
  2. Look up two_factor_secrets. If enabled: false or missing, log the user in.
  3. If enabled:
    • No credentials.totp present → throw new Error("2FA_REQUIRED") — client should re-prompt.
    • credentials.totp matches one of backup_codes → log in + remove used backup code.
    • credentials.totp passes verifyTotp() → log in.
    • Otherwise → throw new Error("Invalid 2FA code").

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.

Each code is spent once. Every door that takes a TOTP code (sign-in, the OAuth challenge, the 2FA-disable and erasure / owner-grant step-ups, and POST /api/internal/two-factor/verify, which rotates the backup codes of an already-enabled factor and also spends the shared per-user step_up_factor budget there) goes through acceptTotp() (src/lib/auth/totp-replay.ts), which claims the matched time step in Redis (SET NX, key per user + step, 120s TTL). The same code is refused at any door for the rest of its window, so a member who just signed in waits for the next code before a step-up. A wrong code claims nothing. Redis down or unconfigured fails closed in production (the TOTP arm refuses; backup codes still work), the same as the strict limiter in front of these doors, and open in dev.

Backup codes

  • 8 codes, 10 hex chars each.
  • Stored as a text[] column on two_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, totp } (passwordless accounts: { totp }) — requires the current password plus a live authenticator or backup code, so a hijacked session cannot strip the factor. On success it also signs out the user's other sessions (keeping the current one), revokes their OAuth / MCP / CLI grants, and emails a "Two-factor authentication was turned off" notice. Full flow: security.md.

Limitations

  • No recovery email flow — if user loses authenticator AND backup codes, admin must delete two_factor_secrets row manually.
  • SMS-based 2FA not supported.
  • No WebAuthn / passkeys yet.