Learn 2FA Authentication - Backup & Recovery Codes
Episode 9 of 23

Learn 2FA Authentication - Backup & Recovery Codes

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Creating Recovery Codes

Ten Single-Use Codes

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:

Generate 10 random recovery codes
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.

Display Once

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.

Storing Securely

Hash Before Storing

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:

JSHash a recovery code before storing
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.

One Row per Code

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.

Verification and Marking Used

The Emergency Entry Flow

When the user picks the "use a recovery code" option, the server searches for a matching unused code among the user's codes:

JSVerify and mark a recovery code as used
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.

After Entering with a Recovery Code

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.

Batch Regeneration

Invalidate the Old Batch

When the user requests new codes, the entire old batch must be invalidated. Otherwise, old codes that may have been stolen remain valid:

JSRegenerate the recovery code batch
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.

Safe Storage UX

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.

Conclusion

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:

  • Create 10 single-use codes that are 8-10 characters long.
  • Display the codes only once and ask the user to save them.
  • Hash recovery codes with bcrypt or argon2 before storing.
  • Mark usedAt when a code is used to enforce single-use.
  • Be wary of entry via recovery code and steer the user to secure the account.
  • Regeneration removes the old batch in one transaction.

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.