Short answer: Use a well-maintained TOTP library rather than implementing RFC 6238 yourself, store the secret encrypted at rest, allow a ±1 time step drift window, rate-limit verification attempts hard, issue single-use backup codes, and require the current password before enabling or disabling 2FA. Then, if you can, add WebAuthn — because passkeys solve the phishing problem that TOTP structurally cannot.

Do not write the algorithm yourself

TOTP is simple enough that implementing it looks like a pleasant afternoon. Resist. Every mainstream language has a mature, audited library, and the value is not in the HMAC — it is in the edge cases those libraries have already found.

Use pyotp (Python), otplib (Node), rotp (Ruby), or the equivalent in your stack. Understanding the mechanism is worthwhile; shipping your own is not. The mechanism itself is covered in what is a TOTP code and TOTP vs HOTP.

Enrolment: get these right

Generate a proper secret

160 bits from a cryptographically secure random source, Base32-encoded. Not a hash of the user ID, not something derived from the email address, not a truncated UUID.

Build the otpauth URI correctly

otpauth://totp/YourApp:[email protected]?secret=BASE32SECRET&issuer=YourApp&algorithm=SHA1&digits=6&period=30

Include the issuer parameter both in the label and as a query parameter — some apps read one, some the other. Use SHA1, 6 digits, 30 seconds unless you have a specific reason not to: those are what every authenticator app assumes, and deviating causes silent failures your users will report as “the code doesn’t work”.

Always show the text key alongside the QR

Users setting up on the same device cannot scan their own screen. A visible manual-entry key is not a nice-to-have — it is the difference between working and not for a large share of mobile users. See manual setup key entry for what they are doing on the other side.

Verify before you activate

Require the user to submit a valid code before you mark 2FA as enabled. Storing a secret without confirming the user can generate codes from it locks people out at scale.

Do not display the secret again afterwards

Once enrolment is confirmed, the secret should never be retrievable through the UI or the API. If a user wants it on another device, they re-enrol.

Storage

Verification

Drift window

Accept the current time step plus one before and after — about 90 seconds total. Wider windows meaningfully increase the guessing surface; narrower ones generate support tickets from users with slightly wrong clocks. Clock drift is the number one cause of user-reported failures, per why 2FA codes get rejected.

Rate limiting — the actual security control

Six digits is a million possibilities. Without rate limiting that is brute-forceable in hours; with it, the attack is dead. Lock or delay after a small number of failures, count per account rather than per IP, and alert on sustained failures. The arithmetic is in are six digits enough?

Prevent code replay

Store the last successfully used time step per user and reject reuse. Otherwise a code captured by a proxy remains valid for the rest of its window — the exact gap real-time phishing kits exploit.

Constant-time comparison

Compare codes with a constant-time function to avoid timing side channels. Most libraries do this; verify yours does.

Backup codes

Terminology matters to users here — be explicit about what your codes do, since the industry is inconsistent (backup codes vs recovery keys).

The flows people get wrong

Then add WebAuthn

TOTP has a structural ceiling: a user can be persuaded to type a code into a fake page, and real-time relay defeats it. No implementation quality fixes this.

WebAuthn does, through origin binding — the browser refuses to produce a credential for the wrong domain. If you support one thing beyond TOTP, make it this. Server libraries are mature, browser support is universal, and passkeys let you offer both stronger security and a faster login.

Background: WebAuthn and FIDO2, phishing-resistant MFA, and passwordless authentication.

Frequently asked questions

Should I offer SMS as an option?

Only if your users genuinely cannot manage an app. It costs money, it fails internationally, and it is SIM-swappable. If you must, do not make it the default.

How do I handle users who lose everything?

Define a recovery path deliberately — identity verification, a support process with delay, or an administrator reset for team accounts. If you do not design it, your support team will improvise one, and improvised recovery is where accounts get socially engineered.

Should 2FA be mandatory?

For privileged roles, yes. For all users, it depends on your audience — but make it easy, and consider enforcing it for anyone with billing or admin access.

What about mobile apps?

Use the standard flows and let the OS handle passkeys. Do not build a bespoke second factor; users already have tools they trust.

Do I need to support HOTP?

Almost certainly not, unless you serve users with legacy hardware tokens. TOTP is the universal expectation.

How should I test this?

Test the drift window at boundaries, replay prevention, rate limiting under load, backup code consumption under concurrent requests, and the disable flow with a stale session. The last two are where bugs hide.

The bottom line

Use a library, encrypt the secret, allow one step of drift, rate-limit aggressively, hash your backup codes, and require re-authentication to change 2FA settings. Get those right and you have a solid TOTP implementation. Then add WebAuthn, because that is the part that actually stops the attacks your users are facing.

Want to see codes generated for testing? Our browser-based generator computes TOTP locally and is useful for sanity-checking an implementation.

Leave a Reply

Your email address will not be published. Required fields are marked *