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.tstwo_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.

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.

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 } — 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_secrets row manually.
  • SMS-based 2FA not supported.
  • No WebAuthn / passkeys yet.