A user enables 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. on Monday. On Wednesday, their phone falls in a lake. They cannot generate TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. codes. They cannot receive push notifications. Their authenticator app, along with every secret stored in it, is gone. If your application has no recovery path, that user is permanently locked out of their account. This is not an edge case. Lost phones, factory resets, broken screens, and stolen devices happen constantly.
Recovery codes exist to solve this problem. They are the last-resort authenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token. method, a set of one-time codes generated during 2FA enrollment that work without any device at all.
How recovery codes work
Recovery codes are randomly generated strings, typically 8-12 codes of 8-10 characters each. They are displayed to the user once during 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. enrollment and never shown again. The user is responsible for storing them securely, printed on paper, saved in a password manager, or locked in a safe.
// Recovery code generation
function generateRecoveryCodes(count = 10) {
const codes = [];
for (let i = 0; i < count; i++) {
// Generate a random 10-character hex code
const code = crypto.randomBytes(5).toString('hex');
// Format as XXXXX-XXXXX for readability
codes.push(`${code.slice(0, 5)}-${code.slice(5)}`);
}
return codes;
}
// Store hashed codes (never store plain text)
async function storeRecoveryCodes(userId, codes) {
const hashedCodes = await Promise.all(
codes.map(async (code) => ({
hash: await bcrypt.hash(code, 10),
used: false,
}))
);
await db.saveRecoveryCodes(userId, hashedCodes);
}The enrollment flow with recovery codes
| Step | Action | Who |
|---|---|---|
| 1 | User enables 2FA (scans QR code, verifies first TOTP code) | User |
| 2 | Server generates 10 recovery codes | Server |
| 3 | Server displays codes to the user with a "Download" or "Copy" button | Server to User |
| 4 | User confirms they have saved the codes (checkbox or re-entry of one code) | User |
| 5 | Server stores hashed codes, marks 2FA as fully enrolled | Server |
| 6 | Recovery codes are never displayed again | , |
The confirmation step (step 4) is important. Without it, users click through the enrollment flow without saving their codes and discover the omission only when they need them. Some applications require the user to type back one of the codes to prove they wrote them down.
Using a recovery code to log in
When a user cannot provide their normal 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. code, they use a recovery code instead. The login flow branches:
// Login with recovery code
async function verifyRecoveryCode(userId, inputCode) {
const storedCodes = await db.getRecoveryCodes(userId);
for (const stored of storedCodes) {
if (stored.used) continue; // Skip already-used codes
const match = await bcrypt.compare(inputCode, stored.hash);
if (match) {
// Mark code as used (single-use)
await db.markRecoveryCodeUsed(userId, stored.id);
// Count remaining codes
const remaining = storedCodes.filter(c => !c.used).length - 1;
return { valid: true, remaining };
}
}
return { valid: false };
}Critical implementation details
Single-use enforcement. Each recovery code works exactly once. After use, it is marked as consumed and cannot be used again. This prevents an attacker who intercepts one code from using it repeatedly.
Remaining code warning. After a recovery code is used, tell the user how many codes remain. When the count drops below 3, display a prominent warning. When it reaches 0, force the user to set up a new 2FA method or generate new codes.
Rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed.. Recovery code verification must be rate-limited just like password verification. Without rate limiting, an attacker can brute-force recovery codes. Ten codes of 10 hex characters is a large space, but rate limiting adds a critical layer of defense.
// Rate limit recovery code attempts
async function verifyWithRateLimit(userId, inputCode) {
const attempts = await db.getRecentAttempts(userId, '2fa_recovery');
if (attempts > 5) {
// Lock account after 5 failed recovery attempts in 15 minutes
await db.lockAccount(userId, 15 * 60 * 1000);
throw new Error('Too many attempts. Account temporarily locked.');
}
await db.recordAttempt(userId, '2fa_recovery');
return verifyRecoveryCode(userId, inputCode);
}Regenerating recovery codes
Users who have used several codes (or lost their saved copy) need to generate new ones. The regeneration flow:
- User navigates to security settings
- User re-authenticates (password + current 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. code)
- Server generates a new set of recovery codes
- All previous codes are invalidated: old codes stop working immediately
- New codes are displayed exactly once
- User confirms they saved the new codes
Invalidating old codes on regeneration is critical. If old codes remain valid alongside new ones, a compromised set of old codes still works. The regeneration must be a clean replacement, not an addition.
Account recovery when all else fails
What happens when a user loses their phone and never saved their recovery codes? This is the hardest problem in 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you.. The options are limited, and none are perfect.
Manual identity verification
The user contacts support and proves their identity through alternative means: government ID, selfie verification, answering account-specific questions, or providing transactionWhat is transaction?A group of database operations that either all succeed together or all fail together, preventing partial updates. history. This is expensive (human review), slow (24-72 hours), and imperfect (social engineering risk). But for high-value accounts, it may be the only option.
Trusted contact recovery
Some platforms allow users to designate trusted contacts who can vouch for them. Facebook uses this approach. The user asks their trusted contacts for recovery codes, and a threshold of responses (e.g., 3 out of 5) unlocks the account.
Time-delayed recovery
The user requests account recovery. The system waits 7-14 days before disabling 2FA. During this period, the real account owner receives notifications and can cancel the request if it was not them. This balances security (attackers cannot get instant access) with usability (legitimate users recover eventually).
| Recovery method | Speed | Security | User friction | Cost |
|---|---|---|---|---|
| Recovery codes | Instant | High (if codes are stored securely) | Low | None |
| Manual identity verification | 24-72 hours | Medium (social engineering risk) | High | High (human review) |
| Trusted contact | Hours to days | Medium (depends on contacts' security) | Medium | Low |
| Time-delayed removal | 7-14 days | High (real owner can cancel) | High | Low |
| No recovery path | Never | N/A (user locked out forever) | Maximum | None |
Account lockout protection
Failed 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. attempts should trigger progressive lockout, just like failed password attempts:
// Progressive lockout for 2FA failures
const LOCKOUT_THRESHOLDS = [
{ attempts: 3, lockoutMinutes: 1 },
{ attempts: 5, lockoutMinutes: 5 },
{ attempts: 8, lockoutMinutes: 15 },
{ attempts: 10, lockoutMinutes: 60 },
];
async function checkLockout(userId) {
const failedAttempts = await db.getFailedTotpAttempts(userId);
for (let i = LOCKOUT_THRESHOLDS.length - 1; i >= 0; i--) {
const threshold = LOCKOUT_THRESHOLDS[i];
if (failedAttempts >= threshold.attempts) {
const lastAttempt = await db.getLastFailedAttempt(userId);
const lockoutEnds = lastAttempt + threshold.lockoutMinutes * 60 * 1000;
if (Date.now() < lockoutEnds) {
const minutesLeft = Math.ceil((lockoutEnds - Date.now()) / 60000);
throw new Error(
`Account locked. Try again in ${minutesLeft} minutes.`
);
}
break;
}
}
}Alert on suspicious activity
When an account experiences multiple failed 2FA attempts, notify the account owner via email. This serves two purposes: it warns the real user that someone may have their password, and it creates an audit trail. A well-designed alert includes the time of the attempts, the IP addressWhat is ip address?A numerical label (e.g., 172.217.14.206) that identifies a device on a network - DNS translates domain names into IP addresses. or geographic location, and a link to review recent account activity.
The complete 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. implementation checklist
When reviewing AI-generated 2FA code, verify every item:
- Enrollment requires verification (user enters a code to confirm setup works)
- Recovery codes are generated and displayed during enrollment
- Recovery codes are stored hashed, not in plain text
- TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. secrets are encrypted at restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. with a key outside the database
- Time drift tolerance is implemented in TOTP verification
- Replay protection prevents the same code from being used twice
- Recovery codes are single-use and marked as consumed after use
- Users are warned when recovery codes are running low
- Code regeneration invalidates all previous codes
- Failed attempts are rate-limited with progressive lockout
- Suspicious activity triggers email notifications
- SessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request. is not created until all factors are verified
- 2FA settings changes require re-authenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token.
- There is a documented account recovery process for when all factors are lost