feat: 2fa backup codes
calcom/cal.com#10600Golden Comments (3/4 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | The exported function TwoFactor handles backup codes and is in BackupCode.tsx. Inconsistent naming. | LOW | ✓ Found | "The component is exported as export default function TwoFactor(...) inside BackupCode.tsx (copy-paste leftover) — rename to BackupCode" (apps/web/components/auth/BackupCode.tsx L9) |
| #2 | Error message mentions 'backup code login' but this is a disable endpoint, not login | LOW | ✗ Not found | No candidate mentions the error message of the disable endpoint. The review's two findings on disable.ts — the unvalidated backup-code branch (L61) and the thrown-vs-JSON 500 (L51) — address the endpoint's logic, never its copy. See apps/web/pages/api/auth/two-factor/totp/disable.ts:50 |
| #3 | Backup code validation is case-sensitive due to the use of indexOf(). This causes validation to fail if a user enters uppercase hex characters, as backup codes should be case-insensitive for a better user experience. | MEDIUM | ✓ Found | "Backup-code matching normalizes only with .replaceAll('-', ''); codes pasted with leading/trailing whitespace (common from the downloaded .txt) or typed in uppercase fail to match and are reported as IncorrectBackupCode" (packages/features/auth/lib/next-auth-options.ts L144) |
| #4 | Because backupCodes are decrypted and mutated in memory before being written back, two concurrent login requests using the same backupCode could both pass this check and update, so a single backup code may effectively be accepted more than once if used concurrently, weakening the intended one-time-use semantics. | HIGH | ✓ Found | "Non-atomic single-use consumption of backup codes (TOCTOU race). The code is verified and consumed via read (indexOf) → write (backupCodes[index] = null; prisma.update) without any lock/transaction. Two concurrent login requests submitting the same valid code can both pass the check, authenticate, and write the consumed array afterward, violating the documented 'each backup code can be used exactly once' guarantee" (packages/features/auth/lib/next-auth-options.ts L148) |
Supplementary Findings (15 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
Closing the dialog via X/ESC/overlay at the DisplayBackupCodes step never calls onEnable() nor resetState(): 2FA is enabled server-side while the switch stays visually off, and reopening shows stale step/codes |
EnableTwoFactorModal.tsx:166 | MEDIUM | ✓ Real UI/data-integrity gap |
Backup codes and the secret are persisted at the password/setup step before 2FA is actually enabled (orphaned if the wizard is abandoned), and each code is only crypto.randomBytes(5) = 40 bits of entropy |
setup.ts:60 | MEDIUM | ✓ Real: abandoned-wizard orphans + low per-code entropy |
| The new E2E additions only exercise backup-code generation (download/copy/close); the login path and error states — the headline of the PR — are untested (the file's own TODO says so) | login.2fa.e2e.ts:12 | MEDIUM | ✓ Real coverage gap for the core feature |
tabIndex={-1} on the shared PasswordField removes keyboard access to the show/hide toggle across ~440 files — an accessibility regression (WCAG 2.1.1) unrelated to this PR |
Input.tsx:49 | MEDIUM | ✓ Real global accessibility regression |
resetState() never calls URL.revokeObjectURL(backupCodesUrl) nor clears the URL, leaking the blob on every cancel/close/re-run |
EnableTwoFactorModal.tsx:71 | LOW | ✓ Real resource leak |
navigator.clipboard.writeText(...) has no .catch() and the success toast fires unconditionally, so users are told codes were copied when the copy failed |
EnableTwoFactorModal.tsx:278 | LOW | ✓ Real: false success feedback |
JSON.parse(symmetricDecrypt(user.backupCodes, ...)) is unguarded; a corrupt or legacy stored value throws out of authorize as a generic 500 |
next-auth-options.ts:139 | LOW | ✓ Borderline: corrupt-data path only, but a silent 500 |
| In lost-access mode the page still renders the "2FA Code" heading and "Submit" label instead of backup-code-context copy | login.tsx:198 | LOW | ✓ Real minor UX inconsistency |
The backup-code branch is gated on truthiness only, so a non-string req.body.backupCode makes .replaceAll(...) throw; the verified code is also never consumed before the bulk delete |
disable.ts:61 | LOW | ✓ Borderline: defensive validation + single-use divergence |
throw new Error(ErrorCode.InternalServerError) yields an HTML 500 instead of the JSON body the modal expects, so the modal's error handling never runs |
disable.ts:51 | LOW | ✓ Real: breaks the API contract the client relies on |
The clipboard test depends on writeText succeeding in headless CI (permission + focus); the toast may never appear and the waitFor times out |
login.2fa.e2e.ts:120 | LOW | ✓ Real flakiness source |
The download test asserts only suggestedFilename(); file content and the XXXXX-XXXXX format are never verified (TODO in the file) |
login.2fa.e2e.ts:113 | LOW | ✓ Real under-assertion |
The first test uses isChecked() (FIXME: "passes even when switch is not checked") while the second uses data-state="checked" — inconsistent assertions across the suite |
login.2fa.e2e.ts:52 | LOW | ✓ Borderline: test hygiene only |
The 7 new i18n keys exist only in English; other locales fall back to en. Matches project convention, so no change needed — flagged as verified only |
common.json:2013 | LOW | ✓ Borderline: convention check, no action needed |
Forward-only ADD COLUMN without DROP COLUMN rollback; standard Prisma convention, flagged because rollback was explicitly checked |
migration.sql:2 | LOW | ✓ Borderline: convention check, no action needed |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌤️ Feat |
|---|---|
| 1 high, 6 medium, 11 low | 1 ok, 2 mitigated |
Review of the 2FA backup-codes MR (feat: 2fa backup codes).
The feature is broadly functional end-to-end: backup codes are generated (10 lowercase-hex codes, display formatted as XXXXX-XXXXX), stored encrypted, usable at login through the "Lost access" toggle, and cleared when 2FA is disabled. The values contract (field name backupCode, code format, error codes, DB column, i18n keys, migration) is internally consistent and was verified across setup/display/login/disable, and the new migration and localization keys are correct.
Several important issues should be addressed before merge, the most significant being:
- A TOCTOU race in
next-auth-options.tsmakes backup-code single-use consumption non-atomic, so the same code can authenticate twice under concurrent requests, violating the documented "each code usable exactly once" guarantee. - The 2FA setup wizard can be dismissed at the backup-code step via X/ESC/overlay without invalidating the parent UI or resetting state, so a user can leave with 2FA actually enabled while the UI still claims it is off, and reopening shows stale step/codes.
- The new E2E tests cover only code generation (download/copy/close), not the backup-code login path or the new error states — which are the headline of this PR. Several test assertions (clipboard, download content, switch state) are also flaky or under-asserted.
- A global accessibility regression:
tabIndex={-1}on the sharedPasswordFieldremoves keyboard access to the show/hide toggle across ~440 dependent files. - Backup codes are committed at setup time (before 2FA is actually enabled) with only 40 bits of entropy per code, clipboard copy reports success unconditionally, and
resetState()leaks a blob URL and stale state.
Non-blocking robustness/consistency notes exist on the disable route (unvalidated body, unconsumed code, thrown-vs-JSON 500) and on login UX/normalization. All sub-reviews completed successfully (no errors/partials); the final status is REQUEST_CHANGES given the single-use race and the missing login-path test coverage.
These might need a close look
- 🔴 packages/features/auth/lib/next-auth-options.ts (L148)
Non-atomic single-use consumption of backup codes (TOCTOU race). The code is verified and consumed via read (indexOf) → write (backupCodes[index] = null; prisma.update) without any lock/transaction. Two concurrent login requests submitting the same valid code can both pass the check, authenticate, and write the consumed array afterward, violating the documented "each backup code can be used exactly once" guarantee. Make consumption atomic (serialize per-user, transaction/row lock, or conditional updateMany).
Worth checking
- 🟡 apps/web/components/settings/EnableTwoFactorModal.tsx (L166)
Closing the dialog via X/ESC/overlay at the DisplayBackupCodes step only firesonOpenChange, which never callsonEnable()(soviewer.me.invalidate()is skipped and the 2FA switch stays visually off even though 2FA is already enabled server-side) and never callsresetState(), leavingstepandbackupCodes/backupCodesUrlstale so reopening shows the backup step again. Only the explicit "close" button handles this correctly; route outside/ESC/X dismissals through the same close path. - 🟡 apps/web/pages/api/auth/two-factor/totp/setup.ts (L60)
Two concerns: (1)backupCodes/twoFactorSecretare persisted at the password/setup step, before 2FA is actually enabled via/totp/enable; if the user abandons the wizard, recovery codes remain stored (encrypted) on an account withtwoFactorEnabled=false. Consider generating/committing at enable time or clearing on abandonment. (2) Each code usescrypto.randomBytes(5).toString('hex')= 40 bits of entropy with a small hex alphabet and a plaintextindexOfcomparison; for a recovery credential that bypasses 2FA, consider longer codes or a larger alphabet to raise per-code entropy. - 🟡 apps/web/components/auth/BackupCode.tsx (L9)
The component is exported asexport default function TwoFactor(...)insideBackupCode.tsx(copy-paste leftover) — rename toBackupCode. Also, generated codes are lowercase hex but the input only constrains length (nopattern/case normalization), and the server verifies with a case-sensitive exactindexOf; uppercase hex (A–F) or the dash-space variant typed/pasted by the user would be rejected even though valid. Normalize (trim + lowercase + strip dashes) on input or server, or add apattern/helper text. - 🟡 packages/features/auth/lib/next-auth-options.ts (L144)
Backup-code matching normalizes only with.replaceAll('-', ''); codes pasted with leading/trailing whitespace (common from the downloaded.txt) or typed in uppercase fail to match and are reported asIncorrectBackupCode. Normalize with.trim().toLowerCase().replaceAll('-', '')on the server (and inBackupCode.tsx), and add a server-side length/format sanity check (e.g. exactly 10 hex chars) since the HTMLminLength/maxLengthare not enforced (noValidate). - 🟡 apps/web/playwright/login.2fa.e2e.ts (L12)
The new E2E additions only exercise the backup-code generation screen (download/copy/close); they do not log out, navigate to login, click "Lost access", enter a backup code, or assertIncorrectBackupCode/MissingBackupCodes. The top-of-file// TODO: add more backup code tests, e.g. login + disabling 2fa with backupconfirms the core feature path of this MR is untested. Add a login-via-backup-code assertion (success) and assertions for invalid/missing code errors; at minimum remove the TODO if deferring. - 🟡 packages/ui/components/form/inputs/Input.tsx (L49)
tabIndex={-1}on the password-visibility toggle is a global change to the sharedPasswordFieldcomponent used across ~440 files, removing keyboard (Tab) access to the show/hide toggle (and its sr-only label) on every password input in the app — an accessibility regression (WCAG 2.1.1) and unrelated to the intended backup-code variant. Prefer removing it, gating via an opt-in prop, or fixing the modal's tab order locally; at minimum document why it was added.
Small things (take or leave)
- 🔵 apps/web/components/settings/EnableTwoFactorModal.tsx (L71)
resetState()only clearspassword,errorMessageandstep. It never callsURL.revokeObjectURL(backupCodesUrl)nor clearsbackupCodesUrl(leaking the blob URL created inhandleSetupon every cancel/close/re-run), and it leavesbackupCodes/dataUri/secretset until the next successful setup. Clear these fields and revoke the URL. - 🔵 apps/web/components/settings/EnableTwoFactorModal.tsx (L278)
navigator.clipboard.writeText(...)has no.catch()andshowToast(backup_codes_copied, "success")fires unconditionally, so users are told codes were copied even when the operation failed (non-secure context, denied permission, unfocused document). Await/then the promise and only toast success on fulfillment, with a fallback (e.g.execCommand('copy')) or manual-copy prompt. - 🔵 packages/features/auth/lib/next-auth-options.ts (L139)
JSON.parse(symmetricDecrypt(user.backupCodes, ...))is unguarded; a corrupt/legacy/malformed storedbackupCodesvalue or unexpected decrypt result throws out ofauthorizeas a generic 500. Wrap in try/catch and fall back to a controlled error, consistent with the defensivesecret.length !== 32check in the TOTP branch. - 🔵 apps/web/pages/auth/login.tsx (L198)
In "lost access" (backup-code) mode the page still renders the "2FA Code" heading and "Submit" button label rather than a backup-code-context label. Minor UX inconsistency; consider switching the heading/label whentwoFactorLostAccessis true. (The mode-toggle state handling that clearstotpCode/backupCodeon switch was verified correct.) - 🔵 apps/web/pages/api/auth/two-factor/totp/disable.ts (L61)
Two issues in the backup-code branch: (1) the branch is gated only on truthiness ofreq.body.backupCode, so a non-string truthy value (e.g. array/object) makes.replaceAll(...)throw a TypeError → unhandled 500; validatetypeof req.body.backupCode === 'string'and normalize input. (2) The verified code is never consumed/nullified before the bulk delete (single-use semantics diverge from the login flow and thebackup_code_instructionscopy); if the final update fails, the same code remains usable for a retry or later login. - 🔵 apps/web/pages/api/auth/two-factor/totp/disable.ts (L51)
throw new Error(ErrorCode.InternalServerError)(on missingCALENDSO_ENCRYPTION_KEY, and analogous TOTP throws) yields an HTML 500 page instead of a JSON{ error }body;response.json()then throws in the modal and is swallowed intosomething_went_wrong, so the modal'sInternalServerErrorhandling never runs. Normalize tores.status(500).json(...)for the JSON contract the modal expects. - 🔵 apps/web/playwright/login.2fa.e2e.ts (L120)
The clipboard-copy test depends on the Clipboard APIwriteTextsucceeding (requiresclipboard-writepermission and focused document); in headless CI the promise may reject, the toast never appears, and thetoast-successwaitFor times out — a flakiness source. Grant clipboard permissions on the context and/or assert the copied text against the known codes rather than relying solely on the toast. - 🔵 apps/web/playwright/login.2fa.e2e.ts (L113)
The download test asserts only thesuggestedFilename()(cal-backup-codes.txt); the// TODO: check file contentmeans it never verifies the file contains the generated codes or theXXXXX-XXXXXformatting, nor cross-checks against the persistedbackupCodescolumn. Readdownload.createReadStream()/download.path()and assert content. - 🔵 apps/web/playwright/login.2fa.e2e.ts (L52)
The first test still usesisChecked()on the two-factor switch despite the file's own FIXME noting it "passes even when switch is not checked", while the second test usesdata-state="checked"— inconsistent assertions across the suite. Migrate the first to[data-testid=two-factor-switch][data-state="checked"](oraria-checked). - 🔵 apps/web/public/static/locales/en/common.json (L2013)
The 7 new localization keys were added only to the English file; other locales render English fallback (fallbackLng=en) until translated. This matches project convention and needs no change, but was verified. Non-blocking copy note:missing_backup_codestext says to generate codes "in your settings," which reads oddly when surfaced in the disable-2FA modal. - 🔵 packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql (L2)
The migration is forward-only (ADD COLUMNbackupCodesTEXT, nullable, no default) with noDROP COLUMNrollback. This matches the Prisma schema (backupCodes String?) and the standard forward-only Prisma convention, so it is acceptable; flagged only because rollback was explicitly checked.
✅ Feature-level checklist
Looks good
- ✅ Fix the 2FA setup test to include the new backup code screen.
The new E2E additions cover the backup-code generation screen (download/copy/close). The broader backup-code login path and error states are not yet covered (acknowledged by a TODO), which affects the login feature's test coverage rather than this specific setup-screen requirement.
Partially covered
- ⚠️ Generate and display backup codes during 2FA setup.
Generation and display work and the values contract (format XXXXX-XXXXX, 10 codes, encrypted storage, client parsing) is consistent. However, backup codes (and the secret) are committed server-side at the password/setup step before 2FA is actually enabled (orphaned if the wizard is abandoned), per-code entropy is only 40 bits, and the wizard's X/ESC/overlay close path leaves stale state and never invalidates the parent UI, so the UI can claim 2FA is off while it is enabled. - ⚠️ Enable use of backup codes during the 2FA login flow.
Login via backup codes works, is rate-limited, and error codes map correctly to i18n keys. But the single-use consumption is non-atomic (TOCTOU), so the same code can authenticate twice under concurrent requests, violating the single-use guarantee; input matching is also not robust to whitespace/uppercase, and unguarded decrypt of stored codes can yield a 500.
FriendlyReviewer found 3 of the 4 golden comments of this PR. The strongest match is golden #4 (HIGH): the TOCTOU race at next-auth-options.ts L148 is described in more depth than the reference — read (
indexOf) → write (backupCodes[index] = null; prisma.update), no lock or transaction, both requests authenticating — and it is the driver of the REQUEST_CHANGES verdict. Goldens #1 (naming) and #3 (case-sensitiveindexOf) are also found, though neither is the headline of its finding: the naming issue is a sub-point of a wider BackupCode.tsx L9 finding, and the case-sensitivity appears both there and at next-auth-options.ts L144.The miss is golden #2 (LOW): the disable endpoint's error message saying 'backup code login'. The review produced two findings on disable.ts — an unvalidated
backupCodebranch (L61) and a thrown-vs-JSON 500 (L51) — yet never quotes the copy it is criticizing. That is the pattern of the miss: FR checks the logic of the endpoint but does not read its user-facing strings, so a purely copy-level comment is invisible even when the file is otherwise covered.The supplementary set (15) is the most useful of the run: the wizard close-path (L166) is a genuine UI/data-integrity gap, the 40-bit entropy and the pre-enable commit of codes (setup.ts L60) are substantive security observations, and the global
tabIndex={-1}(Input.tsx L49, ~440 files) shows FR reasoning about blast radius beyond the PR. The cost is a long tail of test hygiene and copy nits (e2e L113/L120/L52, common.json, migration.sql) that inflate the count. The verdict (REQUEST_CHANGES) is well supported by the TOCTOU race alone; the severity mix (1 high, 6 medium, 11 low) is defensible, though the 11 lows include at least three that are pure convention checks.