Your phone and a server that have never exchanged a packet since enrollment somehow agree on the same six-digit code every 30 seconds — here is the RFC 6238 math that makes that possible, with a worked, verifiable example.
Scan a QR code into Google Authenticator or 1Password, and two devices that have never exchanged a single network packet since that moment will agree on the same six-digit number, every thirty seconds, forever. No push notification, no API call, no clock-sync handshake at verification time. If you've ever wondered how that's possible — or why your code expires mid-type and the next one doesn't match what the server expected — the answer is a 2005-era algorithm called HOTP, repurposed by RFC 6238 to use time instead of a counter.
TOTP (Time-based One-Time Password, RFC 6238) is built on top of HOTP (HMAC-based One-Time Password, RFC 4226). HOTP's idea is simple: you and the server both hold the same secret key K. You both also track the same counter C, incrementing it by one each time a code is used. The code is:
HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))
HMAC-SHA-1 takes the secret and the counter and produces a 20-byte hash that's unpredictable without the key but deterministic given it. "Truncate" turns that 20-byte blob into a human-typeable 6-to-8 digit number. Two parties who never talk to each other produce the same output because they're running the same deterministic function on the same two inputs.
The problem with a counter is synchronization — if your phone generates a code you never use (you switch apps, say), the counter on the device drifts out of sync with the server's counter, and every subsequent code fails until someone manually resyncs. That's annoying enough that counter-based HOTP mostly survives today in hardware tokens (YubiKey's OTP slots, some bank dongles) where a physical button press is the only trigger.
TOTP's fix: replace the counter with something both sides can derive independently without ever being told it — wall-clock time.
T = floor((current_unix_time − T0) / X)
TOTP(K, T) = Truncate(HMAC-SHA-1(K, T))
X is the time step, almost always 30 seconds. T0 is almost always 0 (the Unix epoch). So every device on Earth with a reasonably accurate clock computes the same T at the same moment — no handshake required. That's the entire reason TOTP codes "just work" offline: time is the one piece of shared state that doesn't need a network to stay in sync, within the limits of clock drift.
RFC 6238's Appendix B publishes test vectors so every implementation can verify itself against the same answers. Using the test secret "12345678901234567890" (20 ASCII bytes, base32-encoded as GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ for QR codes), at Unix time 59:
| Step | Value |
|---|---|
Time step X | 30 seconds |
T = floor(59 / 30) | 1 |
T as 8-byte big-endian hex | 0000000000000001 |
| HMAC-SHA1(key, T) | 20-byte hash (unique per key/T) |
| Dynamic truncation, 8 digits | 94287082 |
That 94287082 isn't something to trust blindly — it's the RFC's own published answer, and every conformant TOTP library (Google Authenticator, Authy, pyotp, otplib, Microsoft Authenticator) produces that exact value for that exact key and timestamp. If your own implementation doesn't match it, you have a bug, not a configuration difference.
The "dynamic truncation" step is where most from-scratch implementations go wrong, so it's worth seeing in full:
// hash = 20-byte HMAC-SHA1(key, counterBytes)
const offset = hash[hash.length - 1] & 0x0f; // last nibble picks a byte offset, 0–15
const chunk = (
((hash[offset] & 0x7f) << 24) | // mask the top bit — keeps the result positive
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff)
);
const code = (chunk % 10 ** digits).toString().padStart(digits, '0');
Two details trip people up every time:
chunk % 1_000_000 can produce 82 where the real code is 000082. Treat the result as a zero-padded string, not a number, or you'll silently reject valid codes.| HOTP (RFC 4226) | TOTP (RFC 6238) | |
|---|---|---|
| Moving factor | Counter, incremented per use | floor(unix_time / step) |
| Sync requirement | Server and client counters must match | Server and client clocks must be close |
| Typical drift tolerance | N/A (explicit resync protocol) | ±1–2 time steps accepted by most servers |
| Where you see it | Hardware OTP tokens, some banking dongles | Virtually all authenticator apps |
| Default hash | HMAC-SHA1 | HMAC-SHA1 (SHA256/SHA512 optional per RFC) |
RFC 6238 explicitly permits HMAC-SHA-256 and HMAC-SHA-512 for a larger security margin, and the same RFC publishes test vectors for both (46119246 and 90693936 respectively, for the same T=1 input above). In practice almost nobody uses them: Google Authenticator's implementation ignores the algorithm, digit-count, and period parameters encoded in an otpauth:// URI and always runs 6-digit SHA1 on a 30-second step, a long-standing compatibility quirk that means an issuer who sets algorithm=SHA256 in their QR code will still get a SHA1 code back from that particular app. If you're building anything that has to interoperate with the authenticator apps people actually install, SHA1/6-digits/30s isn't the weak choice — it's the only one guaranteed to work everywhere.
That's also why TOTP's practical security doesn't come from the hash algorithm choice. It comes from the secret never crossing the network after enrollment, the 30-second validity window, and (ideally) rate-limiting and counter-based replay rejection on the server so a leaked code can't be reused within its own window.
The algorithm is the easy 20% of implementing TOTP. The remaining friction is operational:
T-1, T, and T+1 (a ±30-second window) rather than an exact match — which is also why a code can occasionally be accepted a few seconds after its on-screen countdown hits zero.T per user and reject a repeat of that same step, independent of the drift window.0/O and 1/l) and as a QR-encoded otpauth://totp/... URI. Getting the base32 padding and byte-order conventions wrong is the single most common bug in home-rolled TOTP code.If you need to generate a current TOTP code from a secret to test a 2FA integration, or compute an HOTP value for a specific counter, Utilix's TOTP / HOTP code generator runs the RFC 6238/4226 math directly in the browser against SHA-1, SHA-256, or SHA-512, which is a faster sanity check than wiring up a disposable server endpoint just to confirm your secret and expected code actually agree.
The takeaway: TOTP isn't a security protocol in the cryptographic-novelty sense — it's HOTP with time substituted for a counter, which eliminates the one thing (counter synchronization) that made HOTP annoying to deploy at consumer scale. If you remember one thing from the math, remember the truncation offset is dynamic and the output needs zero-padding — that's where almost every bug actually lives.