Most 2FA Bugs Aren't Cryptography Bugs
The HMAC-based algorithm underneath TOTP and HOTP is simple, well-specified, and essentially impossible to get wrong if you follow RFC 6238 or RFC 4226 directly. Almost every real 2FA bug report traces back to something else entirely: which algorithm variant was chosen, how backup codes are stored, or a clock that's drifted by more than the verification window tolerates.
Step 1: Choose TOTP, Not HOTP, Unless You Have a Specific Reason Not To
Both algorithms compute an HMAC over a counter and truncate it into a short numeric code. The only real difference is where the counter comes from:
- TOTP (RFC 6238): counter = current Unix time รท a fixed period (usually 30 seconds). Both sides compute it independently from their own clock โ no state to synchronize.
- HOTP (RFC 4226): counter = an explicit integer that increments every time a code is generated and accepted. Both sides must persist and agree on its current value.
TOTP: counter = floor(unix_time / 30) โ no synchronization needed
HOTP: counter = stored_value, incremented on each use โ must stay in sync
HOTP's counter-sync requirement is exactly the kind of state that drifts in production: a user's token advances (they pressed the button without submitting), a server-side counter update fails to commit, a retry double-increments. TOTP sidesteps all of it by using time, which both sides already have. Unless you're integrating with specific hardware tokens that only speak HOTP, default to TOTP.
Step 2: Get the otpauth:// URL Format Right
Every authenticator app's QR-code setup flow reads the same URL scheme:
otpauth://totp/Issuer:account@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Issuer&algorithm=SHA1&digits=6&period=30
A few details that are easy to get wrong:
issuerappears twice โ once in the label path (Issuer:account) and once as a query parameter. Most apps use the query parameter when present and fall back to the label; set both to the same value for compatibility across apps that only check one.- The label needs URL-encoding if it contains special characters โ a raw
@or space in an unencoded label breaks parsing in some stricter clients. algorithm,digits, andperiodall have defaults (SHA1, 6, 30) that the overwhelming majority of authenticator apps assume even if the parameter is omitted โ but don't omit them if you're using non-default values, since a client that doesn't read a parameter will silently fall back to the default and compute the wrong code.
Step 3: Store Backup Codes Like Passwords, Not Like Data
Backup codes are a full 2FA bypass if they leak. Treat them with the same care as a password:
On generation: show the plaintext once โ hash each code (SHA-256 is sufficient) โ store only the hashes
On redemption: hash the submitted code โ compare against stored hashes โ mark that hash as used
The failure mode to avoid is storing backup codes in plaintext or in a reversibly-encrypted form "just in case you need to show them again" โ you don't. A user who loses their backup codes needs a fresh batch generated, the same way a user who forgets their password gets a reset link, not their old password decrypted and emailed back to them.
Step 4: Handle Clock Drift Before It Becomes a Support Ticket
TOTP verification should check not just the current time step, but a small window around it โ typically the step before and after (ยฑ30 seconds at the default period), to tolerate minor clock drift and the delay between a user reading a code and submitting it.
valid if: computed_code(current_step - 1) == submitted
OR computed_code(current_step) == submitted
OR computed_code(current_step + 1) == submitted
Too narrow a window (checking only the exact current step) causes legitimate codes to fail right at the edge of their 30-second validity. Too wide a window weakens the time-based security property TOTP is built on. ยฑ1 step is the conventional balance.
Quick Reference
| Symptom | Likely cause |
|---|---|
| Code never matches, even right after setup | Secret was transcribed incorrectly, or wrong algorithm/digit-count assumed |
| Code works sometimes, fails intermittently | Server verification window too narrow for normal clock drift |
| HOTP codes drift out of sync after repeated use | Counter desync between client and server โ a known HOTP failure mode TOTP avoids |
| Backup code redemption "works" more than once | Redeemed codes aren't being marked used, allowing replay |
| Database breach exposes working backup codes | Codes were stored in plaintext or reversibly encrypted instead of hashed |
Try It
ToolNinja's TOTP Generator โ and HOTP Generator โ compute live codes from a secret so you can verify your server-side implementation against a known-correct reference. Backup Codes Generator โ produces a realistic code set with SHA-256 hashes shown side by side, and QR Code Scanner โ decodes an existing otpauth:// QR code when you need to check what it actually contains.
Sources: