Recovery Codes
When the phone is gone
The user enabled 2FA with their phone. Then their phone is lost, stolen, broken, or factory-reset. The authenticator app and its secrets are gone. The user cannot generate TOTP codes. Without a backup, they are locked out of their account.
Recovery codes solve this. At 2FA setup, the app generates a set of one-time-use codes that the user saves offline (printed, written down, or stored in a password manager). Each code works exactly once, in place of a TOTP code.
Generating recovery codes
// src/recovery.ts
import db from "./db.js";
function generateCode(): string {
// 8 alphanumeric characters, grouped for readability
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let code = "";
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
for (const b of bytes) {
code += chars[b % chars.length];
}
return code.slice(0, 4) + "-" + code.slice(4);
}
async function hashCode(code: string): Promise<string> {
const normalized = code.replace(/-/g, "").toLowerCase();
const data = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function generateRecoveryCodes(userId: string): Promise<string[]> {
// Delete any existing codes
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(userId);
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
const code = generateCode();
const codeHash = await hashCode(code);
const id = crypto.randomUUID();
db.prepare("INSERT INTO recovery_codes (id, user_id, code_hash, used) VALUES (?, ?, ?, 0)").run(
id,
userId,
codeHash,
);
codes.push(code);
}
return codes; // Return plain-text codes to show the user ONCE
}
export async function verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
const codeHash = await hashCode(code);
const row = db
.prepare("SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used = 0")
.get(userId, codeHash) as { id: string } | undefined;
if (!row) return false;
// Mark as used (single-use)
db.prepare("UPDATE recovery_codes SET used = 1 WHERE id = ?").run(row.id);
return true;
} Key design choices:
10 codes. This is the industry standard (Google, GitHub, Stripe). Enough for occasional use, not so many that the user ignores them.
Hashed storage. Like passwords and API keys, recovery codes are hashed before storage. If the database is breached, the codes are unusable.
Single-use. After a code is used, it is marked used = 1 and cannot be used again. This limits the damage if a code is compromised.
Normalized before hashing. The code is lowercased and dashes are removed before hashing. This means ab3f-k2m9 and AB3FK2M9 both work.
Showing codes at 2FA setup
Update the 2FA verify endpoint to generate recovery codes when 2FA is enabled:
// In the /me/2fa/verify handler, after enabling 2FA:
db.prepare("UPDATE totp_secrets SET enabled = 1 WHERE user_id = ?").run(user.id);
const recoveryCodes = await generateRecoveryCodes(user.id);
return Response.json({
message: "2FA is now enabled. Save your recovery codes — they will not be shown again.",
recoveryCodes,
}); The response includes the recovery codes in plain text. The user must save them now. They cannot be retrieved later (only the hashes are stored).
[!WARNING] Tell the user clearly: “Save these codes now. They will not be shown again.” Display them in a copy-friendly format. Some apps offer a “Download as text file” option.
Using a recovery code at login
Update the /login/2fa endpoint to accept either a TOTP code or a recovery code:
route.post("/login/2fa", {
resolve: async (c) => {
// ... get pending session and user ID ...
const body = await c.request.json();
const { code, recoveryCode } = body;
if (recoveryCode) {
// Try recovery code
const valid = await verifyRecoveryCode(pendingUserId, recoveryCode);
if (!valid) {
return Response.json({ error: "Invalid recovery code" }, { status: 401 });
}
} else if (code) {
// Try TOTP code
const row = db
.prepare("SELECT secret FROM totp_secrets WHERE user_id = ? AND enabled = 1")
.get(pendingUserId) as { secret: string } | undefined;
if (!row || !verifyTOTPCode(row.secret, code)) {
return Response.json({ error: "Invalid 2FA code" }, { status: 401 });
}
} else {
return Response.json({ error: "Provide code or recoveryCode" }, { status: 400 });
}
completePendingSession(sessionId);
// ... return user ...
},
}); The user submits either { "code": "123456" } (TOTP) or { "recoveryCode": "ab3f-k2m9" } (recovery). Both paths complete the pending session.
Exercises
Exercise 1: Enable 2FA and save the recovery codes. Log out. Log in with password, then use a recovery code instead of a TOTP code. Verify it works.
Exercise 2: Use the same recovery code again. It should fail because it was marked as used.
Exercise 3: Check how many unused recovery codes remain. Write a query: SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used = 0.
Why are recovery codes hashed before storage?