threshold1

Backup Codes

One-time recovery codes generated in a batch, for when a user's primary second factor — a TOTP app, an SMS-registered phone — isn't available. Generation follows the same step-up shape as TOTP; verification is the one real exception.

Generating codes — requires an existing session

await auth.login("user@example.com"); // establish a session first

const { codes } = await auth.generateBackupCodes();
// 10 codes, shown to you exactly once — store them nowhere else,
// display them once for the user to save.

Generating a new batch immediately invalidates every previously-generated code for that user — this matches how GitHub and Google handle backup-code regeneration. There's no way to view a previously-generated batch again; if the user lost them, generate a fresh set.

Verifying a code — the real exception

// No session required — this IS a login, the account-recovery path.
const user = await auth.verifyBackupCode("user@example.com", "ABCDE-FGHJK");
// or: auth.verifyBackupCode({ externalUserId: "your_user_123" }, "ABCDE-FGHJK")

Unlike enrollTotp()/verifyTotp()/sendSmsOtp(), verifyBackupCode() does not require an existing session — the user may have none, which is the entire point of a recovery code. A successful call establishes a brand-new session and fires onAfterAuth, exactly like verifyOtp() does. It is deliberately not part of the automatic register()/login() cascade — a backup code only exists if the user already saved one, and silently checking "is this a valid backup code?" during every ordinary login attempt would be surprising, unrequested behavior. Call this directly from a "Use a backup code instead" path you build.

Real configuration values

  • 10 codes per generation.
  • 10 characters each, Crockford-style alphanumeric alphabet that excludes visually ambiguous characters (0/O, 1/I/L) — roughly 50 bits of entropy per code.
  • Display format: XXXXX-XXXXX. Entry is tolerant of formatting — dashes, spacing, and case are all normalized server-side, so however the user types it back works.
  • Stored bcrypt-hashed, not the SHA-256 this codebase otherwise uses for one-time credentials — a backup code is functionally a password and gets password-grade hashing.
  • Verify rate limit: 10 attempts per user per 15 minutes.

None of these are currently configurable.

Failure codes

verifyBackupCode() returns invalid_code for a wrong or already-used code, and rate_limited past the 15-minute attempt limit. See the Error Reference for details.

What's next

  • TOTP — the primary step-up factor backup codes exist to recover from.