TOTP, HOTP, and Why Your 2FA Actually Works

By Λ · May 18, 2026 · 7 min read

You open Google Authenticator, see a 6-digit code, type it into the login form, and the server says yes. There is no network call from your phone to the server. The server does not have a list of "codes you have given out". You both arrive at the same number through math. This post is about that math.

HOTP first, then TOTP

RFC 4226 (2005) defines HOTP, HMAC-based One-Time Password. The recipe:

  1. The user and the server share a secret K (random bytes, typically 160 bits).
  2. The user and the server share a counter C, starting at 0 and incrementing on each use.
  3. Code = truncate(HMAC-SHA1(K, C)) modulo 10^6 for a 6-digit code.

The counter is the clever part. Every code is a one-shot: once used, the counter advances, and that code is invalid forever. The server stores the last counter it accepted; codes that arrive with a counter at or below the last-accepted are rejected.

HOTP is rare in the wild because synchronizing counters between client and server is annoying. If the user presses "generate" three times before logging in, the counter drifts. RFC 4226 specifies a look-ahead window (the server accepts codes for the next N counter values), but users hate the experience.

TOTP: the counter is just time

RFC 6238 (2011) replaces the counter with a time-based value. The recipe:

T = floor(unix_time / 30)
code = truncate(HMAC-SHA1(K, T)) % 10^6

That is the whole change. The counter is derived from wall-clock time divided by the period (30 seconds is the universal default). The user and server do not need to synchronize a counter; they just need clocks within ~30 seconds of each other.

The advantages:

The trade-off: codes are reusable within their 30-second window. If an attacker observes a code being entered and races to enter it themselves, they have until the window ends. Real implementations track recently-accepted codes to prevent same-window replay.

The "truncate" step

HMAC-SHA1 produces 160 bits (20 bytes). You need a 6-digit number. The truncation is more careful than just "take the last 6 digits":

function truncate(hmac):
    offset = hmac[19] AND 0x0F            // last 4 bits of last byte
    binary = (hmac[offset]   AND 0x7F) << 24
           | (hmac[offset+1] AND 0xFF) << 16
           | (hmac[offset+2] AND 0xFF) << 8
           | (hmac[offset+3] AND 0xFF)
    return binary mod 10^6                // for 6-digit code

The variable-offset is to defeat any structural bias an attacker might exploit. The high bit is masked to avoid sign-bit confusion in languages with signed integers.

Why 6 digits and not 8?

RFC 6238 allows 6, 7, or 8 digits. Almost every authenticator app assumes 6 because that is what Google Authenticator shipped and the ecosystem standardized on. If you set up an account with 7 or 8 digits, most apps will silently treat it as 6 and fail every verification. Stick with 6 unless your authenticator explicitly says it supports more.

Clock drift

The "30 seconds" window is small enough that clock drift becomes a real problem. If the user's phone is 35 seconds slow, they enter a code that was valid 35 seconds ago, which is no longer the current window. Real servers handle this by checking the previous and next windows:

function verify(code, secret, period=30, drift_windows=1):
    now = floor(now() / period)
    for off in range(-drift_windows, drift_windows + 1):
        if hotp(secret, now + off) == code:
            return True
    return False

One window of drift (±30 seconds, total tolerance 90 seconds) is the most common setting. Strict servers use zero drift (current window only) and cause occasional "code wrong even though it's right" complaints.

SHA-1 in 2026?

SHA-1 is broken for collision resistance. So why is the TOTP standard still SHA-1? Because TOTP only needs the HMAC property, not collision resistance, and HMAC-SHA1 is still secure (no practical attacks). The RFC allows SHA-256 and SHA-512 variants, but almost no authenticator app supports them. Stick with SHA-1 for compatibility.

Implementation pitfalls I have seen

1. Using time in milliseconds instead of seconds

The math expects floor(unix_time / 30) where unix_time is seconds since epoch. If you accidentally pass milliseconds, your T value is 1000x too high, and your codes look random instead of matching. The token is technically valid in some far-future window.

2. Wrong byte order

The counter T must be encoded as 8 bytes big-endian for the HMAC input. JavaScript's DataView.setBigUint64 defaults to big-endian, which is correct. Manual byte assembly sometimes gets this wrong, producing codes that match no authenticator app.

3. Forgetting Base32 decoding

Authenticator apps display the secret as Base32 (uppercase, no padding). Your code must decode it back to bytes before HMAC. Hex-decoding a Base32 string produces gibberish that "works" enough to generate codes (just not matching anyone else's codes).

4. Replay protection

Real implementations remember the most-recently-accepted code (or counter) per user and reject reuse. Without this, an attacker who captures one code can replay it within the same window. Most TOTP libraries do this automatically; many homemade implementations do not.

When NOT to use TOTP

For very high-stakes accounts: hardware security keys (FIDO2/WebAuthn) are stronger because they bind the auth to the specific domain and survive phishing. TOTP is phishable; a user who types a code into a lookalike site has handed it to the attacker.

For SMS-based 2FA: SMS 2FA is weaker than TOTP because SIMs get swapped and SMS gets intercepted. Use TOTP as the upgrade path.

For ultra-frequent auth (every request): TOTP rotates every 30 seconds, which is annoying for "tap to confirm payment" UX. Use signed tokens (JWT or similar) backed by a more stable authentication factor.

Related