Generating and Storing MFA Recovery Codes

A user lost the phone with their authenticator app and now cannot log in — your recovery-code flow is the difference between a self-service fix and a support ticket that ends in a risky manual reset. Recovery codes are the fallback factor in the Multi-Factor Authentication: TOTP and FIDO2 guide, and because each one bypasses the second factor, they must be generated, stored, and redeemed with the same rigor as any password.

Why Recovery Codes Are Credentials, Not Conveniences

A recovery code does exactly what an attacker wants a stolen second factor to do: it satisfies the MFA step on its own. That makes a set of recovery codes a high-value secret. Three properties follow, and getting any of them wrong silently undoes your MFA:

  1. Unpredictable — generated from a CSPRNG, never a timestamp, counter, or weak RNG. If codes are guessable, MFA is theater.
  2. Stored hashed — like passwords, never in plaintext. A database leak must not hand an attacker working bypass codes for every account.
  3. Single-use — each code is invalidated the instant it is redeemed, so a shoulder-surfed or logged code cannot be replayed.

Because the user must be able to read and type them, codes are shown once at generation time and never again; you store only their hashes, exactly as you would for a password.

Recovery code lifecycle from generation to redemption Codes are generated from a cryptographic random source, hashed with a password key-derivation function and stored unused, displayed to the user exactly once, and on redemption matched in constant time and atomically marked as used. Generate CSPRNG · N codes Hash each argon2id or bcrypt Store hashes state: unused Show once never again User redeems one rate-limited attempt Constant-time match atomic update marks it used
The only copy of the plaintext lives in the response that displays it. Everything after that point works on hashes.

Step 1 — Generate Codes With a CSPRNG

Use node:crypto. Encode to an unambiguous alphabet (no 0/O, 1/l) so users transcribe them correctly, and give each code enough entropy that the whole batch is infeasible to brute-force even against a hashed store.

import { randomInt } from "node:crypto";

const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O/1/I/L ambiguity

function oneCode(length = 10): string {
  let out = "";
  for (let i = 0; i < length; i++) {
    // randomInt is CSPRNG-backed and unbiased.
    out += ALPHABET[randomInt(ALPHABET.length)];
  }
  // Group for readability: ABCDE-FGHJK
  return `${out.slice(0, 5)}-${out.slice(5)}`;
}

export function generateRecoveryCodes(count = 10): string[] {
  return Array.from({ length: count }, () => oneCode());
}

A 10-character code over a 31-symbol alphabet carries ~50 bits of entropy — far beyond brute-force reach when each candidate must be checked against an argon2/bcrypt hash and a rate limiter.

Step 2 — Hash Each Code Before Storage

Hash with a password KDF — argon2id (preferred) or bcrypt. Do not use a fast hash like SHA-256: recovery codes have lower entropy than a strong password, and a fast hash makes an offline guessing attack against a leaked table cheap.

import argon2 from "argon2";

export async function hashCodes(codes: string[]): Promise<string[]> {
  return Promise.all(
    codes.map((c) =>
      argon2.hash(normalize(c), { type: argon2.argon2id, memoryCost: 19456, timeCost: 2 }),
    ),
  );
}

// Normalize so display formatting never causes a false mismatch.
function normalize(code: string): string {
  return code.replace(/[-\s]/g, "").toUpperCase();
}

Persist only the resulting hashes plus an used flag and usedAt timestamp. The plaintext codes exist solely in the response that displays them once.

export async function storeRecoveryCodes(userId: string, plain: string[]) {
  const hashes = await hashCodes(plain);
  await db.recoveryCodes.deleteMany({ userId }); // replace any prior set
  await db.recoveryCodes.insertMany(
    hashes.map((hash) => ({ userId, hash, used: false, usedAt: null })),
  );
}

Step 3 — Display Once, Then Never

Return the plaintext codes exactly once, on the generation response, and make the UX unambiguous that they will not be shown again — offer copy and download, and require an explicit “I’ve saved these” confirmation before clearing them from the screen. Never email the codes, never write them to logs, and never expose an endpoint that re-reads them, because there is nothing to read: you only stored hashes.

Step 4 — Redeem With Single-Use Invalidation

On redemption, rate-limit first, then check the candidate against the user’s unused hashes. Because hashes are one-way, you must try the candidate against each unused row.

import argon2 from "argon2";

export async function redeemRecoveryCode(userId: string, input: string): Promise<boolean> {
  if (await isRateLimited(userId)) throw new Error("RATE_LIMITED");

  const candidate = normalize(input);
  const rows = await db.recoveryCodes.find({ userId, used: false });

  for (const row of rows) {
    if (await argon2.verify(row.hash, candidate)) {
      // Atomic single-use: only succeeds if still unused.
      const claimed = await db.recoveryCodes.updateOne(
        { id: row.id, used: false },
        { used: true, usedAt: new Date() },
      );
      if (claimed.modifiedCount === 1) {
        await maybeWarnLowCodes(userId);
        return true;
      }
    }
  }
  await recordFailure(userId);
  return false;
}

The conditional update ({ id, used: false }) makes invalidation atomic, so two concurrent requests with the same code cannot both succeed — a real race in a double-submit or retry. bcrypt.compare/argon2.verify are constant-time, so the loop does not leak which row matched via timing.

Step 5 — Regeneration

Let users regenerate their set at any time, and force it after a code is used or after a security event (password change, device loss). Regeneration must replace the entire set — invalidate all old hashes — so a previously captured but unused code cannot be redeemed later.

export async function regenerateRecoveryCodes(userId: string): Promise<string[]> {
  const codes = generateRecoveryCodes(10);
  await storeRecoveryCodes(userId, codes); // deleteMany inside replaces the old set
  return codes; // display once
}

Security Implications

  • Plaintext storage: the single worst failure. A leaked table of plaintext codes is a master key to every MFA-protected account. Hash with argon2id/bcrypt so a leak yields only expensive guessing.
  • Weak generation: codes from Math.random() or a timestamp are predictable and let an attacker derive valid codes without ever seeing them. CSPRNG only.
  • Replay: without atomic single-use invalidation, one observed code works repeatedly. The conditional update closes the redemption race.
  • Assurance downgrade: a recovery-code login is a fallback, not a strong factor — do not let it satisfy AAL3 step-up guards. Treat it as reduced assurance and consider prompting re-enrollment of a real factor immediately after.
  • Brute force: even hashed, low-entropy codes plus no rate limiting invite online guessing. Rate-limit per account and per IP, and lock out after repeated failures (OWASP ASVS V2.2).

Prevention & Monitoring Hooks

  • Alert when a recovery code is redeemed — it should be rare; a burst signals account compromise or a broken authenticator-enrollment flow.
  • Warn the user (and drop a metric) when their unused-code count falls to one or two, prompting regeneration before lockout.
  • Log every RATE_LIMITED and failed redemption with IP; many failures across IPs for one account is an active guessing attack.
  • Audit-log generation, regeneration, and each redemption with timestamps so support can distinguish legitimate recovery from takeover.

Frequently Asked Questions

Why hash recovery codes with bcrypt/argon2 instead of SHA-256?

Recovery codes have less entropy than a strong random password, so if a leaked table were hashed with a fast function like SHA-256, an attacker could test billions of candidates per second offline and recover working codes. A memory-hard or deliberately slow KDF (argon2id, bcrypt) makes each guess expensive, which — combined with the codes’ entropy — pushes offline recovery out of reach. Use a fast hash only for high-entropy opaque tokens, never for human-typed codes.

How many codes should I issue and how long should each be?

Ten codes of roughly 8–10 characters over an unambiguous ~30-symbol alphabet is the common, well-balanced choice: enough codes to survive several recoveries before regeneration, and enough entropy per code (~40–50 bits) that guessing is infeasible against a hashed, rate-limited store. Strip ambiguous characters (0/O, 1/I/L) so users transcribe them correctly under stress.

What stops two requests from redeeming the same code at once?

The atomic conditional update. Rather than reading used, deciding, then writing, the redemption issues an update guarded by used: false and checks that exactly one row changed. The database serializes the two writes, so only the first claims the code; the second sees zero rows modified and fails. Without this, a fast double-submit or a retry could let one code authenticate two sessions.

Should I regenerate codes after one is used?

Not necessarily after every use, but you must invalidate the used code and warn the user as the unused count runs low. Always fully regenerate — replacing the entire set — after a security event such as a password change, suspected compromise, or device loss, so any code an attacker may have captured but not yet redeemed becomes worthless. Regeneration replaces all old hashes, not just the consumed one.

Can a recovery-code login bypass step-up authentication?

It should not for high-assurance actions. A recovery code re-establishes access but is a fallback, so treat it as lower assurance — do not let it satisfy an AAL3 step-up guard that expects a phishing-resistant factor. A safe pattern is to grant a normal session on recovery, then require the user to re-enroll a real second factor before performing sensitive operations.

Where Recovery Codes Sit in the Assurance Model

A recovery code is a bypass, so treating it as equivalent to the factor it replaces quietly downgrades every protection built on top of that factor.

What a recovery-code login should and should not permit A recovery-code login should restore ordinary access and allow enrolling a new factor, but should not satisfy a step-up check for high-assurance operations such as disabling multi-factor authentication or changing the recovery address. Permitted after a recovery login read and ordinary use enrol a replacement factor prompt for enrolment immediately Still requires a real factor disabling multi-factor entirely changing the recovery address these are the takeover objectives
Someone who redeems a recovery code has proved possession of a printed secret, not possession of the user's device — the distinction should survive into your authorization checks.

Operationally, treat every redemption as a security event worth a notification. Tell the account owner through a channel that does not depend on the credential just used, drop the remaining-code count into the response so the user knows when they are running low, and force regeneration of the whole set after any password change or suspected compromise, so a set captured earlier becomes worthless.

Redemption Under Concurrency

Two concurrent redemptions of the same recovery code A read-then-write redemption lets both requests observe the code as unused and both succeed; a conditional update guarded on the unused state lets only the first modify the row, so the second fails. Read, decide, write both requests read "unused" both succeed — one code, two sessions Conditional update update … where used = false one row changed — the second fails
The database, not the application, has to arbitrate. Checking the row count of a guarded update is the whole fix.