Second factors and recovery Required
Give recovery codes with the first second factor
When someone turns on a second factor, give them 10 single-use recovery codes of at least 64 random bits each, and store only their hashes.
How to do it
Make each code from a secure random source, such as 13 characters of base32 for 65 bits. NIST SP 800-63B-4 section 4.2.1.1 sets the 64-bit minimum for saved recovery codes. Show the codes once, with buttons to copy, download and print. Hash each one like a password and mark it used on success. A new set cancels the old one. Email a notice whenever a code is used. Warn in the app when 2 or fewer are left.
Example
import { randomInt } from 'node:crypto';
const CROCKFORD_BASE32 = '0123456789abcdefghjkmnpqrstvwxyz';
const CODE_LENGTH = 13; // 13 characters of 5 bits each give 65 bits
const CODE_COUNT = 10;
export const newRecoveryCodes = () => Array.from({ length: CODE_COUNT }, () =>
Array.from({ length: CODE_LENGTH }, () => CROCKFORD_BASE32[randomInt(CROCKFORD_BASE32.length)]).join(''));
import secrets
CROCKFORD_BASE32 = "0123456789abcdefghjkmnpqrstvwxyz"
CODE_LENGTH = 13 # 13 characters of 5 bits each give 65 bits
CODE_COUNT = 10
def new_recovery_codes() -> list[str]:
return ["".join(secrets.choice(CROCKFORD_BASE32) for _ in range(CODE_LENGTH)) for _ in range(CODE_COUNT)]
How to check
Sign in with one recovery code, then try it again. The second use fails, and the notice email arrived. No code appears in plain text in the database.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers accounts playbook: https://agentwranglers.se/playbook/accounts/second-factors-and-recovery/recovery-codes.