This episode covers recovery codes as the way out when the authenticator is lost: creating 10 single-use codes, displaying them once, storing them as hashes like passwords, marking used codes, and replacing the whole batch on regeneration.

A fact feature designers often forget: users' devices can be lost, broken, or have their apps wiped. Without a way out, users get locked out of their own accounts — and support tickets start flooding in. Episode 9 covers recovery codes, the emergency keys that open the door when the authenticator isn't available.
Recovery codes must be treated as seriously as passwords: generated randomly, displayed once, stored as hashes, and valid only once. You'll build their whole lifecycle — generation, display, verification, and regeneration — so this feature doesn't become a new security hole.
The standard practice is to create 10 single-use codes that are 8-10 characters long. Because a code is used once, a batch of 10 covers around 10 recoveries before regeneration. Generate them with cryptographic randomness, not Math.random:
node -e "
const crypto = require('crypto');
for (let i = 0; i < 10; i++) {
console.log(crypto.randomBytes(6).toString('hex').toUpperCase());
}
"Each output line is one 12-character hex code. A minimum length of 8 characters gives a sufficiently large search space, while a format without ambiguous characters makes codes easy to type in an emergency.
The codes must only be displayed once, on a screen that asks the user to copy and save them before continuing. Store the codes as copyable strings, and give a firm warning that this screen won't appear again. Once the screen is closed, the server no longer stores the plaintext codes — only their hashes.
Just like passwords, recovery codes must not be stored in plaintext. Hash them with bcrypt or argon2 before they go into the database. This ensures that if the database leaks, the codes can't be used directly:
const { recoveryCodes } = require('../db');
async function storeRecoveryCodes(userId, codes) {
for (const code of codes) {
const codeHash = await bcrypt.hash(code, 10);
await recoveryCodes.create({ data: { userId, codeHash } });
}
}recoveryCodes.create stores one row per code with a codeHash column and an unused status. Hashing per code slows down offline brute-force if the table leaks.
The table structure uses one row per code, not one long string, so marking used codes and rejecting already-used codes is easy. Episode 12 will cover the full schema.
When the user picks the "use a recovery code" option, the server searches for a matching unused code among the user's codes:
const matches = await recoveryCodes.findMany({ where: { userId } });
for (const row of matches) {
if (await bcrypt.compare(code, row.codeHash) && !row.usedAt) {
await recoveryCodes.update({
where: { id: row.id },
data: { usedAt: new Date() }
});
return res.json({ ok: true });
}
}
res.status(400).json({ error: 'Kode tidak valid' });Two conditions are mandatory on a matching row: the hash matches and usedAt is still empty. After a code is used, usedAt is filled so the same code is rejected on the next attempt — this is the single-use property that must be enforced strictly.
It's important to mark that the user entered via a recovery code. Many systems show a warning: the authenticator device isn't confirmed, and re-enabling 2FA or regenerating codes is recommended. A user who got in without their device must be steered to secure the account again as quickly as possible.
When the user requests new codes, the entire old batch must be invalidated. Otherwise, old codes that may have been stolen remain valid:
await recoveryCodes.deleteMany({ where: { userId } });
const freshCodes = generateCodes(10);
await storeRecoveryCodes(userId, freshCodes);recoveryCodes.deleteMany removes all old codes before the new batch is stored. This operation should be wrapped in a transaction, and may only run after the user proves their identity — for example with their password and a current TOTP code.
When displaying the codes, give concrete storage suggestions: a password manager, an encrypted notes app, or paper stored in a safe place. Never offer automatic browser storage as the only option, and warn users not to keep codes in screenshots that get backed up to a public cloud.
Episode 9 built recovery codes from scratch: 10 single-use codes generated randomly, displayed once, stored as hashes, verified once, and replaceable as a whole batch with one click.
The key takeaways:
In the next episode, episode 10, we will cover rate limiting and replay protection — limiting code attempts with express-rate-limit, and tracking the last time-step so the same code can't be reused within a 30-second window.