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:
- Unpredictable — generated from a CSPRNG, never a timestamp, counter, or weak RNG. If codes are guessable, MFA is theater.
- Stored hashed — like passwords, never in plaintext. A database leak must not hand an attacker working bypass codes for every account.
- 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.
flowchart LR
A["CSPRNG\ngenerate N codes"]:::store --> B["Hash each\nargon2id / bcrypt"]:::idp
B --> C["Persist hashes\nunused"]:::idp
A --> D["Display once\nto user"]:::client
D --> E["User redeems\none code"]:::client
E --> F["Constant-time match\nmark used"]:::rs
classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
classDef idp fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
classDef store fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
classDef rs fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614
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_LIMITEDand 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.
Related
- Multi-Factor Authentication: TOTP and FIDO2 — where recovery codes fit in the overall MFA factor model and assurance levels.
- Implementing TOTP enrollment and verification in Node — the authenticator factor recovery codes back up.
- Enforcing step-up authentication for sensitive actions — why a recovery-code login should carry reduced assurance.