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=30Include 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
- Encrypt secrets at rest with a key held outside the database — a KMS, a secrets manager, or at minimum an application key not stored in the same place. A database dump should not hand over everyone’s second factor.
- Never log secrets, and make sure they cannot appear in error reports, request logs or APM traces. This is a surprisingly common leak.
- Exclude them from backups that have weaker protection than the primary store, or ensure those backups are encrypted equivalently.
- Never expose them via any API endpoint, including admin ones.
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
- Generate 8 to 10 at enrolment.
- Hash them like passwords — bcrypt or Argon2. They are credentials, not tokens.
- Single use, marked consumed atomically.
- Show them once, offer download, and prompt regeneration when few remain.
- Regenerating invalidates the whole previous set.
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
- Require the current password to enable and disable 2FA. Disabling without re-authentication is a gift to anyone with a stolen session.
- Notify by email when 2FA is enabled, disabled, or backup codes are regenerated. A user who did not do it now knows. The transactional or marketing email platform you send through is a credential worth hardening as well — see securing your marketing email platform.
- Do not let password reset bypass 2FA. If a reset link grants full access, your 2FA is decorative. Require the second factor after reset too.
- Invalidate sessions when 2FA is disabled or the secret changes.
- Handle “remember this device” carefully — bind the trust token to the session and give it a finite lifetime. See the user-side trade-off.
- Do not reveal whether 2FA is enabled during login before authentication succeeds; it leaks account information.
- API tokens are a parallel authentication system. Scope them, expire them, and be honest in your docs that they bypass 2FA — the mistake users make at GitLab, Stripe and everywhere else.
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.