Olympus Docs
CookbookAuth flows

Recovery codes, best practices

Backup codes UX done right

When users enroll MFA, generate 10 recovery codes. These let them recover if they lose their primary MFA device. Critical for avoiding lockouts.

Generation

function generateRecoveryCodes(): string[] {
  return Array.from({ length: 10 }, () =>
    crypto.randomBytes(6).toString("hex").match(/.{4}/g)!.join("-")
  );
  // e.g. "a3b2-4c8f-9e21"
}

10 codes, each ~48 bits of entropy. Each one-time use.

Display

Once. Never again.

<div>
  <h2>Save your recovery codes</h2>
  <p>If you lose access to your authenticator, you can use one of these codes to sign in.</p>
  <p>⚠️ Save them now. They won't be shown again.</p>
  
  <ul>
    {codes.map(code => <li><code>{code}</code></li>)}
  </ul>
  
  <Button onClick={downloadAsTxt}>Download as text file</Button>
  <Button onClick={copyAll}>Copy to clipboard</Button>
  <Button onClick={printPage}>Print</Button>
  
  <Checkbox checked={confirmed} onChange={setConfirmed} 
            label="I have saved these codes somewhere safe" />
  <Button disabled={!confirmed} onClick={proceed}>Continue</Button>
</div>

Make user actively confirm. Don't let them skip past.

Storage

Hash, don't store plaintext:

CREATE TABLE recovery_codes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  identity_id UUID NOT NULL,
  code_hash TEXT NOT NULL,
  used_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const hash = await argon2.hash(code, { /* params */ });
await db.insert(recoveryCodes).values({
  identity_id: id,
  code_hash: hash,
});

Even if DB leaks, attackers can't recover the codes.

Validation

async function useRecoveryCode(identityId: string, code: string): Promise<boolean> {
  const codes = await db`
    SELECT id, code_hash FROM recovery_codes
    WHERE identity_id = ${identityId} AND used_at IS NULL
  `;
  for (const rc of codes) {
    if (await argon2.verify(rc.code_hash, code)) {
      await db`UPDATE recovery_codes SET used_at = NOW() WHERE id = ${rc.id}`;
      return true;
    }
  }
  return false;
}

Each code one-time. Mark as used.

Regeneration

User loses codes? Generate new ones (invalidates old).

async function regenerateRecoveryCodes(identityId: string) {
  await db`DELETE FROM recovery_codes WHERE identity_id = ${identityId}`;
  return await issueRecoveryCodes(identityId);
}

Notify via email: "Your recovery codes were regenerated. If this wasn't you, contact support."

Threshold warnings

When user has used 7+ of 10:

<Banner>
  Only 3 recovery codes remaining. Generate new ones to be safe.
  <Button>Regenerate</Button>
</Banner>

Don't run out completely.

UI flow when using a code

User clicks "Use a recovery code"

Input field for code

Submit → server validates

Success → grant session at AAL2
        → Show: "8 of 10 codes remaining. Generate new set?"

After using, urge regeneration (the old codes are no longer 10, now 9 if you keep them, 0 if regenerate).

Actually: best to regenerate after use, so user always has 10. Mark old as invalid.

Trade-off: more rotation, harder for the user to keep current.

For most: keep using existing, regenerate when down to <3.

Common bad patterns

Showing codes after they've left the screen

// BAD
<button>Show my recovery codes</button>

This shouldn't work unless they re-authenticate fully. Codes should only be visible at generation time.

Emailing codes

Some sites email recovery codes after generation. If email is compromised, codes are leaked.

Don't.

Codes in audit logs

Make sure recovery codes don't appear in logs:

logger.info({ event: "code_used" });  // not code value

Adversarial considerations

Helpful threat: lost device, scared user

User loses phone with authenticator. Codes (in 1Password) save them. Good outcome.

Less helpful: phishing for codes

Attacker tricks user: "Enter your recovery code to verify your account." User types one. Attacker has it.

Mitigation:

  • Educate users: "We never ask for recovery codes via email or chat."
  • Codes are entered only at our official login page (URL bar shows real domain).

WebAuthn passkeys are stronger here, can't be phished. But codes are the fallback when even passkeys fail.

Account takeover then code regeneration

Attacker compromises account, regenerates codes (the real user's old codes invalidated). User locked out.

Mitigations:

  • Notify user via email on code regeneration.
  • Require existing MFA factor to regenerate.
// Pre-regenerate hook
if (!hasRecentMfa(req.session)) {
  return forceMfaStepUp();
}

User must prove they have the MFA factor (TOTP / WebAuthn) before regenerating.

What if they don't save codes?

Some users won't. They click "Continue" without saving.

Options:

  • Force download or print before proceeding.
  • Show a confirmation modal: "Are you SURE you've saved them? Without them, you'll be locked out if you lose your device."

UX vs security: at some point, users get to choose. If they don't save, they accept the lockout risk.

On this page