Recovery codes are the escape hatch of the MFA system. When a user loses their phone, swaps authenticator apps, or gets a new device, a recovery code is the only thing that gets them back into their account without a support ticket. This makes them critically important for user experience — and critically dangerous if you get the implementation wrong. A poorly implemented recovery code system is effectively a backdoor that bypasses the MFA you've worked to enforce.
This post covers cryptographic generation, hashed storage, the display-once UX contract, single-use enforcement, batch invalidation, and the admin recovery flow that you'll need eventually.
Generating codes cryptographically
Recovery codes must be generated with a cryptographically secure random number generator. Math.random(), Python's random module, and UUIDs based on timestamp entropy are not acceptable. You want OS-level entropy: crypto.randomBytes in Node.js, secrets in Python, crypto/rand in Go.
import secrets
import string
def generate_recovery_codes(count: int = 10) -> list[str]:
"""
Generate recovery codes in the format XXXXX-XXXXX (10 chars, human-readable).
Each code has ~52 bits of entropy — sufficient for rate-limited login.
"""
alphabet = string.ascii_uppercase + string.digits
# Remove ambiguous characters: 0/O, 1/I/L
alphabet = alphabet.replace('0', '').replace('O', '').replace('1', '')
alphabet = alphabet.replace('I', '').replace('L', '')
codes = []
for _ in range(count):
part1 = ''.join(secrets.choice(alphabet) for _ in range(5))
part2 = ''.join(secrets.choice(alphabet) for _ in range(5))
codes.append(f"{part1}-{part2}")
return codes
# Example output: ['B4KWR-X9MZP', 'QFVNH-3TYCG', ...]
The hyphenated 5-5 format is a deliberate UX decision. Groups of 5 characters are easy for humans to read, transcribe, and verify. Including only unambiguous characters prevents support tickets from users who typed a zero instead of an O.
Storage: hashed, not plaintext
Recovery codes should be stored hashed, just like passwords. If your database is breached, plaintext recovery codes give the attacker immediate bypass of every user's MFA. Bcrypt is the correct choice — it's deliberately slow and includes a salt.
import bcrypt
from datetime import datetime, timezone
def store_recovery_codes(user_id: str, codes: list[str], db) -> None:
# Invalidate any existing codes first
db.execute(
"UPDATE recovery_codes SET invalidated_at = NOW() WHERE user_id = $1 AND used_at IS NULL AND invalidated_at IS NULL",
user_id
)
for code in codes:
# bcrypt automatically generates a salt
code_hash = bcrypt.hashpw(
code.upper().replace('-', '').encode(),
bcrypt.gensalt(rounds=10)
).decode()
db.execute(
"""
INSERT INTO recovery_codes (user_id, code_hash, created_at)
VALUES ($1, $2, $3)
""",
user_id, code_hash, datetime.now(timezone.utc)
)
def verify_recovery_code(user_id: str, submitted_code: str, db) -> bool:
"""
Try submitted code against all active recovery codes for this user.
O(n) bcrypt comparisons — acceptable since n is small (10) and rate limiting is enforced.
"""
normalized = submitted_code.upper().replace('-', '').replace(' ', '').encode()
active_codes = db.fetchall(
"""
SELECT id, code_hash FROM recovery_codes
WHERE user_id = $1 AND used_at IS NULL AND invalidated_at IS NULL
""",
user_id
)
for row in active_codes:
if bcrypt.checkpw(normalized, row['code_hash'].encode()):
# Mark as used immediately — single use enforcement
db.execute(
"UPDATE recovery_codes SET used_at = NOW() WHERE id = $1",
row['id']
)
return True
return False
The display-once contract
Recovery codes must be shown to the user exactly once: immediately after generation, before you store the hashes. After the user dismisses the page, the plaintext is gone. This is intentional — it forces users to store the codes themselves, rather than assuming they can always retrieve them from your app.
The UX pattern that works: show all 10 codes, require the user to acknowledge "I have saved these codes" (a checkbox, not just a button), then dismiss. Offer a "Download as text file" button that downloads a plain text file — this is more durable than screenshots.
// POST /api/mfa/recovery-codes/generate
export async function generateRecoveryCodes(req: Request, res: Response) {
const user = req.user!;
// Verify they've completed MFA setup — don't allow generation before TOTP is active
if (!user.mfaEnabled) {
return res.status(400).json({ error: 'MFA must be enabled before generating recovery codes' });
}
const plainCodes = generateRecoveryCodes(10);
// Store hashes in DB (invalidates old codes)
await storeRecoveryCodes(user.id, plainCodes, db);
// Log the event
await auditLog.write({
userId: user.id,
action: 'recovery_codes_generated',
ip: req.ip,
userAgent: req.headers['user-agent'],
});
// Return plaintext ONCE — never stored anywhere in our system after this response
return res.json({
codes: plainCodes,
warning: 'Store these codes securely. They will not be shown again.',
generatedAt: new Date().toISOString(),
});
}
// GET /api/mfa/recovery-codes — only return count, never the codes themselves
export async function getRecoveryCodeStatus(req: Request, res: Response) {
const count = await db.recoveryCode.countActive(req.user!.id);
return res.json({ activeCount: count, total: 10 });
}
Batch invalidation on use
When a user successfully authenticates with a recovery code, invalidate all remaining codes immediately. This sounds aggressive, but the logic is sound: if a recovery code was used, either the user lost their authenticator (in which case they need to re-enroll MFA and get fresh codes), or an attacker used a stolen code (in which case the remaining codes are compromised too).
def consume_recovery_code(user_id: str, submitted_code: str, db) -> bool:
if not verify_recovery_code(user_id, submitted_code, db):
return False
# Invalidate all remaining codes
db.execute(
"""
UPDATE recovery_codes
SET invalidated_at = NOW(), invalidation_reason = 'sibling_used'
WHERE user_id = $1 AND used_at IS NULL AND invalidated_at IS NULL
""",
user_id
)
# Flag the account: user must re-enroll MFA or generate new codes
db.execute(
"UPDATE users SET mfa_recovery_required = TRUE WHERE id = $1",
user_id
)
return True
After using a recovery code, route the user through a flow that re-enrolls their authenticator (or explicitly skips it with a warning about running without MFA). Generate new recovery codes as part of that re-enrollment. Never leave a user with zero active codes and no way to generate new ones.
Admin recovery flow
You will have users who lose both their authenticator and their recovery codes. This happens. You need an out-of-band recovery process that doesn't undermine your security posture. The key principles:
- Identity verification before any recovery: photo ID, video call, or other out-of-band channel depending on your security requirements.
- Admin-initiated recovery generates a one-time recovery link, not a bypass code. The link expires in 15 minutes.
- The recovery action is logged with the admin's identity and rationale.
- The user is notified by email that account recovery was performed.
CREATE TABLE recovery_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
used_at TIMESTAMPTZ,
invalidated_at TIMESTAMPTZ,
invalidation_reason VARCHAR(64), -- 'regenerated', 'sibling_used', 'admin_reset'
generation_batch UUID NOT NULL -- groups codes created together
);
CREATE INDEX idx_recovery_codes_user_active
ON recovery_codes(user_id)
WHERE used_at IS NULL AND invalidated_at IS NULL;
The generation_batch field lets you invalidate all codes from a specific generation event if you discover a compromise, without touching codes from later batches. It also gives you audit visibility into exactly when each batch was created and how many have been consumed.