How TOTP Really Works: HMAC, a Clock, and Three Things That Bite
The first time I shipped TOTP I set algorithm=SHA256 in the enrolment URI, because SHA-1 has been deprecated everywhere else and it felt like the responsible choice. Every test passed. My server generated a code, my server verified it, round trip green. Then the first real person scanned the QR with Google Authenticator, typed the six digits it showed, and was told they were wrong. So were the next six digits, and the six after that, forever.
Google Authenticator had read my algorithm parameter, ignored it, and computed SHA-1 anyway. Nothing errored. Nothing logged. The two sides simply computed different numbers from the same secret for the rest of time. That is the shape of most TOTP bugs: the maths is trivial and completely specified, and every painful failure lives in the ecosystem around it.
HOTP is a counter, TOTP is a clock, and both are HMAC underneath
RFC 4226 (December 2005) defined HOTP: take a shared secret and a counter, compute HMAC(secret, counter), squeeze the 20-byte result down to six digits. Every time the user asks for a code, the counter increments on both sides.
RFC 6238 (May 2011) defined TOTP as one substitution on top of that. Replace the counter with the number of time steps since an epoch:
T = floor((unix_time - T0) / X) # T0 = 0, X = 30 seconds
TOTP = HOTP(secret, T)
That is the entire difference. TOTP is HOTP whose counter both parties can derive independently because they both own a clock. Everything else — the 30-second window, the six digits, the base32 secret — is convention layered on an HMAC construction that has not changed since 1997.
Here it is end to end, in code you can run:
function totpAt(string $key, int $counter, int $digits = 6, string $algo = 'sha1'): string
{
// RFC 4226: the counter is an 8-byte big-endian integer.
$hash = hash_hmac($algo, pack('J', $counter), $key, true);
// Dynamic truncation: the low nibble of the final byte picks where to read.
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
$number = ((ord($hash[$offset]) & 0x7F) << 24) // high bit masked - see below
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| ord($hash[$offset + 3]);
return str_pad((string) ($number % 10 ** $digits), $digits, '0', STR_PAD_LEFT);
}
function totpNow(string $key, int $step = 30): string
{
return totpAt($key, intdiv(time(), $step));
}
$key is the raw secret, not the base32 text the user sees — PHP has no base32 decoder in core, so decode it first with something like ParagonIE\ConstantTime\Base32::decodeUpper(). If you want to watch the HMAC step in isolation before wiring any of this up, computing HMAC-SHA1 over an 8-byte counter by hand with an HMAC generator is the fastest way to confirm your byte packing is right.
Dynamic truncation is the only clever line in the specification
You have 20 bytes of HMAC output and you need six digits. The obvious move — take the first four bytes — is what RFC 4226 explicitly avoids. Instead the low nibble of the last byte selects an offset between 0 and 15, and the code is read from four bytes starting there.
The reason is that the offset itself is derived from the hash, so an attacker collecting codes never learns a fixed window into the HMAC output. Which four bytes leaked depends on bytes they cannot see.
The & 0x7F on the first byte is the other detail people copy without knowing why. It clears the most significant bit so the 31-bit result is unambiguously positive on any platform that treats the value as a signed 32-bit integer. Drop that mask and your implementation works fine until the high bit happens to be set, at which point Java and C produce a negative number, % returns a negative remainder, and roughly one code in two fails on a schedule that looks completely random.
The algorithm parameter is a trap, and SHA-1 is the right answer anyway
RFC 6238 blesses SHA-256 and SHA-512. The otpauth:// URI has an algorithm field. Most authenticator apps do not surface it in the UI, and several read it and discard it — Google Authenticator and Authy default to SHA-1 regardless, and legacy enterprise SSO products commonly assume SHA-1 without looking. As of 2026, SHA-1 is still the only interoperable choice, and choosing anything else buys you a silent enrolment failure per user.
The instinct to avoid SHA-1 is sound in general and wrong here. The attacks that killed SHA-1 are collision attacks: an adversary constructs two different inputs hashing to the same value. HMAC does not depend on collision resistance — it needs the underlying function to behave as a pseudorandom function under a key, and HMAC-SHA1 has no practical break. This is the one place a deprecated hash is still the correct engineering call, which is worth keeping straight when you are choosing between MD5, SHA-1 and SHA-2 anywhere else, where the answer is emphatically not SHA-1.
Set the field to SHA1, or leave it out and let the default apply. Do not be clever.
Your enrolment QR is the shared secret, in the clear
The QR code you show at setup encodes a URI in this shape:
otpauth://totp/Rich%20Dev%20Tools:ada@example.com
?secret=JBSWY3DPEHPK3PXP
&issuer=Rich%20Dev%20Tools
&algorithm=SHA1&digits=6&period=30
secret is the shared key, base32-encoded. Base32 is not encryption and not obfuscation — anyone who photographs that screen owns the second factor permanently, because unlike a password nobody is ever prompted to rotate it. The practical consequences are all procedural: the QR must never appear in a support screenshot, the URI must never reach an application log, and the setup page needs Cache-Control: no-store like any credential page.
There is a detail here that still surprises people who assume security formats are rigorously standardised. The otpauth:// scheme — the format carrying essentially every 2FA secret ever enrolled — was never an RFC. It is a wiki page in the google/google-authenticator repository, adopted by the entire industry through sheer convention, and only in the last couple of years have IETF drafts appeared trying to write it down properly. That is why implementations disagree about label encoding, about whether issuer is authoritative when it contradicts the label prefix, and about the algorithm field.
When you render it, remember the QR is carrying roughly 100 characters of dense text with a long URI prefix, so error-correction level and version choice matter more than for a short link — the capacity and error-correction trade-offs are exactly the ones that decide whether a phone camera locks on at arm's length. If you are prototyping an enrolment screen, generating the otpauth:// string with a QR code generator and scanning it with a real app is a five-minute check that catches encoding mistakes no unit test will.
Drift, replay, and the window you must bound
Two clocks will disagree. The standard answer is to accept a small window either side of the current step:
function verifyTotp(string $key, string $code, int $window = 1, int $step = 30): bool
{
$current = intdiv(time(), $step);
for ($i = -$window; $i <= $window; $i++) {
// hash_equals: constant time, because $code is attacker-supplied.
if (hash_equals(totpAt($key, $current + $i), $code)) {
return true;
}
}
return false;
}
$window = 1 gives you ±30 seconds and a 90-second acceptance band. Every step you add multiplies the number of valid codes at any instant, so a "generous" window of 10 turns a six-digit code into something with the guessing resistance of five digits. One is right for phones. Larger windows belong only to hardware tokens with genuinely drifting crystals, and then paired with drift tracking rather than a permanently wide band.
Two things the RFCs leave to you, both mandatory:
Store the last accepted step per user and refuse anything at or below it. Without this, a code stays valid for its whole window, and anyone who observes it — over a shoulder, in a phishing form, in a log — can replay it for the remainder. TOTP has no built-in replay protection.
Rate limit verification. Six digits is a million possibilities; a 90-second window with unlimited attempts is not a second factor, it is a delay. Lock the attempt counter to the account, not the IP.
HOTP has the mirror-image problem: because the counter only advances when a code is generated, the client can run ahead of the server if a user presses the button idly. Servers handle this with a look-ahead window — try the next n counters, resynchronise on a hit — which is exactly why HOTP is now confined to hardware tokens and TOTP won everywhere a clock exists.
TOTP is phishable by design, and always was
A TOTP code is a bearer token with a thirty-second lifetime. Whoever holds it can use it. That is the whole security model, and it means an adversary-in-the-middle proxy defeats it completely: the fake login page collects the password and the six digits, relays both to the real site within the window, and the user sees a successful login. Nothing about the code is bound to the site requesting it.
This is not a flaw in anyone's implementation. It is the gap the FIDO stack was built to close, and the numbers now reflect that. Passkeys and hardware security keys are the only methods measured at 0% phishing success at Google scale, because the authenticator verifies the site's identity before it will respond at all. FIDO's 2026 figures put passkey use at 75% of consumers having enabled at least one, with 49% saying they use them whenever they can.
| Factor | Phishing-resistant | Works offline | Recovery burden | Server cost |
|---|---|---|---|---|
| SMS code | no | no | low | per message |
| TOTP app | no | yes | backup codes | none |
| Push approval | no (fatigue attacks) | no | low | vendor |
| Passkey / FIDO2 | yes | yes | device loss story | none |
Where TOTP still wins is unglamorous and durable: it costs nothing to run, needs no network on the client, works on a decade-old phone, and requires no relationship with a vendor. That is why the honest 2026 position is not "TOTP is obsolete" — it is that TOTP should no longer be your strongest factor for anything that matters, while remaining an entirely reasonable one to offer. The same reasoning applies further down the stack when you decide whether a generated password or a passphrase protects the account underneath, because a second factor never repairs a weak first one.
Ship TOTP, Plan for Passkeys
Offer passkeys as the default and TOTP as the compatible fallback, and implement the fallback exactly as the RFCs describe rather than as your instincts suggest: SHA-1, six digits, thirty-second period, a window of one, a stored last-used counter, and a rate limit on verification.
Then treat the enrolment secret with the seriousness the format does not signal. It is base32 text on a screen that never expires, cannot be rotated without re-enrolling the user, and grants a permanent second factor to anyone who photographs it. Every real TOTP incident I have seen started there, not in the arithmetic.