Security [HIGH]: MFA TOTP replay-race + missing backup codes #43
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
(1) TOTP validation in auth.ts is SELECT → validate → UPDATE without atomic CAS. Two parallel requests with the same valid code both see
lastTotpAt=nulland both succeed → MFA replay-within-window. (2) No backup/recovery codes exist — lost device forces admin to disable MFA, a security regression.Evidence
apps/web/src/server/auth.ts:151,134,174 — non-atomic read/validate/write sequencepackages/api/src/router/user-self-service-procedure-support.ts:214-224,276-287 — same patternGrepbackupCode|recoveryCode→ 0 hitsImpact
(1) Stolen TOTP code (shoulder-surf, phishing proxy) usable twice within 30 s window — MFA design promise violated. (2) Lost-device MFA recovery requires admin to disable MFA → temporary 1FA state is routine.
Proposed Fix
(1) Atomic CAS:
prisma.user.updateMany({ where: { id, OR: [{lastTotpAt: null}, {lastTotpAt: {lt: windowStart}}] }, data: { lastTotpAt: now }})— rows-affected=0 → replay. (2) Add 8-10 backup codes (argon2-hashed, single-use) generated at MFA enablement, displayed once, confirmed consumed in DB.Acceptance Criteria
Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (A-7, A-8)
Part 1 — TOTP replay race — resolved in commit
3222bec(security: atomic compare-and-swap for TOTP replay window).packages/api/src/lib/totp-consume.ts::consumeTotpWindow()runs a singleuser.updateMany({ where: { id, OR: [{ lastTotpAt: null }, { lastTotpAt: { lt: windowStart } }] }, data: { lastTotpAt: now } }). Postgres row-locks that one statement, so two concurrent verifies serialize and exactly one receives{ count: 1 }.apps/web/src/server/auth.ts(login path),packages/api/src/router/user-self-service-procedure-support.ts(verifyAndEnableTotp + verifyTotp).packages/api/src/lib/__tests__/totp-consume.test.tsincluding a simulated race.Part 2 — Backup codes — deferred. Tracked as a follow-up; will need a Prisma migration (
totpBackupCodes String[]+ hashed storage), recovery-code UI, and backend consume-on-use logic. Not blocking deploy.Closing Part 1; part 2 will open as a new ticket.