← Back to blog
Security·October 28, 2026·6 min read

How TOTP Codes Actually Work: The HMAC Math Behind Your Authenticator App

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.

How TOTP Codes Actually Work: The HMAC Math Behind Your Authenticator App

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.

The core trick: turn a shared secret into a moving target

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.

Walking through a real code

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:

StepValue
Time step X30 seconds
T = floor(59 / 30)1
T as 8-byte big-endian hex0000000000000001
HMAC-SHA1(key, T)20-byte hash (unique per key/T)
Dynamic truncation, 8 digits94287082

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:

TOTP vs. HOTP vs. the digest variants

HOTP (RFC 4226)TOTP (RFC 6238)
Moving factorCounter, incremented per usefloor(unix_time / step)
Sync requirementServer and client counters must matchServer and client clocks must be close
Typical drift toleranceN/A (explicit resync protocol)±1–2 time steps accepted by most servers
Where you see itHardware OTP tokens, some banking donglesVirtually all authenticator apps
Default hashHMAC-SHA1HMAC-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.

Where the deployment actually gets hard

The algorithm is the easy 20% of implementing TOTP. The remaining friction is operational:

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.

#totp#hotp#2fa#hmac#security#authentication

Related reading

Security
JWK Rotation in Production: What Actually Breaks When You Rotate a Signing Key
Security
Why Your Email Regex Is a Denial-of-Service Waiting to Happen
Security
JWT vs Session Cookies: The Trade Isn't Statelessness, It's Revocation