Add TOTP MFA Enrollment with Recovery Codes
Generate a provisioning QR code, verify a first code before enabling, store the secret encrypted, and issue single-use recovery codes.
Task
Implement TOTP enrollment that cannot lock users out and cannot be brute-forced, with recovery codes as the escape hatch.
When to use this
MFA rollout plans stall because everyone remembers the support cost of users who lost their phone. Enrollment needs a design that survives device loss.
Prerequisites
- A TOTP library such as otplib or python-totp with RFC 6238 semantics
- Encrypted storage for the shared secret
- A QR code renderer for the provisioning URI
Steps
1. Generate the secret and build the provisioning URI.
const authenticator = require("otplib").authenticator;
const secret = authenticator.generateSecret(20);
const uri = authenticator.keyuri(user.email, "ExampleCorp", secret);
// otpauth://totp/ExampleCorp:[email protected]?secret=...
Expected output: The otpauth URI encodes issuer, account, and secret, ready to render as the enrollment QR code.
2. Verify one live code before the factor activates.
const ok = authenticator.verify({ token: req.body.token, secret });
if (!ok) return res.status(400).json({ error: "code did not match" });
await users.enableTotp(userId, encrypt(secret));
Expected output: The factor only turns on after the user proves the authenticator works, preventing enrollment into a broken state.
3. Issue single-use recovery codes at enrollment time.
const recovery = Array.from({ length: 10 }, () =>
crypto.randomBytes(5).toString("hex") // 10 codes
);
await users.storeRecoveryHashes(userId, recovery.map(sha256));
Expected output: The user gets ten one-time codes once; the server stores only their hashes, consumed one by one.
4. Rate-limit verification to defeat brute force.
app.post("/login/totp",
rateLimit({ windowMs: 60_000, limit: 5, keyGenerator: byUser }),
(req, res) => { /* verify */ });
Expected output: Six-digit codes cannot be guessed: five attempts per minute per account makes exhaustive search hopeless.
5. Test the lockout path end to end.
node -e 'const a=require("otplib").authenticator; console.log(a.generate(process.env.TOTP_SECRET))' && curl -s -X POST https://api.example.com/login/recovery -d '{"code": "..."}'Expected output: A valid recovery code authenticates, is marked consumed, and the flow guides the user to re-enroll a new device.
Troubleshooting
Problem
Codes always rejected after correct enrollment
Solution
Server clock drift versus the authenticator; verify with a current token and allow a one-step window on either side, then fix NTP on the hosts.
Problem
Users locked out after losing the phone
Solution
Recovery codes are the designed path; if they are also lost, a support-verified identity re-enrollment flow is required, and it must be as slow as identity verification deserves.
Problem
QR code scans as invalid
Solution
The provisioning URI is malformed; use the library keyuri builder rather than string concatenation, and include the issuer parameter.
Limitations
- TOTP secrets are phishable in real time; for strong threats consider WebAuthn as the phishing-resistant factor.
- Recovery codes are as sensitive as passwords; deliver them once and store only hashes.
Enrollment is a contract, not a checkbox
Toggling an MFA-enabled flag the moment a user scans a QR code creates lockouts when the scan failed silently and breach paths when secrets sit in plaintext. Sound enrollment is a contract: the user proves the factor works before it activates, the secret is stored encrypted, and ten single-use recovery codes are issued at the exact moment the user is paying attention. The design then survives the day the phone falls in a lake.
// 1. Secret + provisioning URI
const secret = authenticator.generateSecret(20);
const uri = authenticator.keyuri(user.email, "ExampleCorp", secret);
// 2. Activate only after a verified live code
if (!authenticator.verify({ token: req.body.token, secret })) {
return res.status(400).json({ error: "code did not match" });
}
await users.enableTotp(userId, encrypt(secret));
The first code closes the loop
Verifying one current token before enabling turns enrollment from a hope into a test: the secret, the clock, and the user device are all proven to agree. It also defeats the scan-failed lockout, where a user believes MFA is on but their authenticator never stored the secret. One verification closes both failure modes at once.
Recovery codes are the product decision
Every MFA rollout succeeds or dies on the lockout path. Ten single-use codes, delivered once at enrollment and stored as hashes, convert lost-phone disasters into a two-minute self-service flow. Skimp on them and support tickets decide your MFA policy for you; make them first-class and the rollout survives contact with real users.