When you open an authenticator app, six digits appear and a ring counts down thirty seconds. It feels like the app is "generating a random code." It isn't. The code is a deterministic function of exactly two inputs — a shared secret and the current time — and the most interesting part of the whole scheme is a tiny, almost arbitrary-looking step that decides which four bytes of a 20-byte hash become your code. That step is dynamic truncation, and it is the reason the algorithm is both fast and safe to reduce a 160-bit value down to something you can type with your thumbs.
Here is how it actually works under the hood.
TOTP is HOTP with a clock bolted on
Two RFCs define this. RFC 4226 specifies HOTP, a counter-based one-time password. RFC 6238 specifies TOTP, which is HOTP with the counter replaced by time:
HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))
TOTP = HOTP(K, T)The only difference is where the moving factor C comes from. In HOTP it's an event counter incremented on every use. In TOTP it's derived from the wall clock:
T = (Current Unix time - T0) / Xwith a floor division. RFC 6238 sets the defaults as T0 = 0 (the Unix epoch) and X = 30 seconds. So every 30 seconds, T ticks up by one, and the authenticator feeds that integer into the exact same HOTP machinery. That's the countdown ring: it's just visualizing when T increments.
Because T is derived independently on both sides from a shared secret and a shared clock, nothing is ever transmitted to generate a code. The server computes the same T, runs the same HMAC, and checks for a match.
The HMAC is the easy part
The first stage is a standard HMAC-SHA-1(K, T), which produces 20 bytes. RFC 4226 is strict about K: the shared secret "MUST be at least 128 bits," and the document "RECOMMENDs a shared secret length of 160 bits" — matching SHA-1's output width. (RFC 6238 additionally says implementations "MAY use HMAC-SHA-256 or HMAC-SHA-512," though SHA-1 remains the default and is still what most authenticator apps assume.)
Now you have 20 bytes of uniformly random-looking output and you need six decimal digits. The naive approach — take the first 4 bytes, mod 10^6 — works, but it throws away most of the hash and bakes in a fixed extraction point. RFC 4226 does something cleverer.
Dynamic truncation: let the hash pick its own window
This is the step worth slowing down for. The truncation function reads the low-order 4 bits of the last byte (String[19]) and uses that nibble — a value from 0 to 15 — as a byte offset into the hash:
"Let OffsetBits be the low-order 4 bits of String[19]"
It then extracts the 4 bytes starting at that offset and masks off the top bit:
"Let P = String[Offset]...String[Offset+3]; Return the Last 31 bits of P"
The result is a 31-bit unsigned integer, which is reduced mod 10^Digit to produce the final code (minimum 6 digits per the spec).
Two design choices in that paragraph are easy to skim past and both matter:
Why the offset is dynamic. If you always extracted bytes 0–3, an attacker analyzing many codes would only ever see the statistical behavior of those four bytes. By letting the hash's own last nibble choose the window, every output byte of the HMAC has a chance to influence the code, and the extraction point varies unpredictably between codes. RFC 4226's security analysis concludes that, for practical purposes, "the outputs of the dynamic truncation on distinct inputs are uniformly and independently distributed strings."
Why the high bit is masked. The spec takes the "last 31 bits," not 32, by ANDing the top byte with 0x7f. The reason is painfully pragmatic:
"The reason for masking the most significant bit of P is to avoid confusion about signed vs. unsigned modulo computations. Different processors perform these operations differently, and masking out the signed bit removes all ambiguity."
A 32-bit value with the high bit set is negative in a signed 32-bit integer, and -N % 10^6 differs between languages and CPUs. Dropping one bit is cheaper than auditing every implementation's integer semantics — a nice example of a spec choosing interoperability over one bit of entropy.
Walk the canonical test vector
RFC 4226's Appendix D pins this down with a worked example. Shared secret is the ASCII string 12345678901234567890, counter C = 0. The HMAC-SHA-1 is:
cc93cf18508d94934c64b65d8ba7667fb7cde4b0Now apply dynamic truncation by hand:
- Last byte is
0xb0=1011 0000. Its low 4 bits are0000, so offset = 0. - Four bytes at offset 0 are
cc 93 cf 18. - Mask the top byte:
0xcc & 0x7f = 0x4c, giving0x4c93cf18= 1284755224. - Mod 10^6:
1284755224 % 1000000= 755224.
That 755224 is the exact value the RFC lists. Every correct implementation on earth produces it for that secret and counter — a useful smoke test when you're writing your own.
The TOTP vectors in RFC 6238 Appendix B use the same secret with 8-digit output. At Unix time 59 the SHA-1 code is 94287082; at 1111111109 it's 07081804; at 1234567890 it's 89005924. If your implementation reproduces those three, your time-step math and byte ordering are correct.
The part applications get wrong
The algorithm is only half the system. The validation side carries the real security burden, and RFC 4226 is explicit about two server-side parameters.
First, throttling. Because the code space is small, an online guessing attack is the actual threat. The spec recommends "setting a throttling parameter T, which defines the maximum number of possible attempts" before lockout. The success probability is approximated as:
Sec = s * v / 10^Digitwhere v is the number of verification attempts allowed and s is the look-ahead window size. With 6 digits and no throttling, v grows without bound and Sec approaches 1 — brute force wins. Rate limiting is not optional hardening; it's load-bearing.
Second, the look-ahead / drift window. Clocks drift and packets take time, so servers accept codes from adjacent time steps. RFC 6238 is deliberately conservative here: "We RECOMMEND that at most one time step is allowed as the network delay." Every extra step you accept to paper over clock skew multiplies s, and per the formula above, directly multiplies an attacker's odds. A server that accepts ±5 steps to be "forgiving" has quietly made guessing an order of magnitude easier.
What to take away
The six digits on your screen are the tail end of a precise pipeline: HMAC-SHA-1 over a time counter, a self-selected 4-byte window, one masked bit for portability, and a modulo. None of it is secret — the algorithm is fully public and reproducible from the two RFCs. The only secret is K. That's the whole point of the design, and it's why the security questions that matter in practice aren't about the math at all. They're about protecting the shared secret, throttling guesses, and keeping the acceptance window as narrow as your clocks allow.
