What you'll learn
Quick Answer
A TOTP app doesn't contact any server to generate your 6-digit code — it takes the shared secret from setup, combines it with the current time rounded down to a 30-second window, runs that through HMAC-SHA1, and truncates the result to 6 digits. Because your phone and the server started with the identical secret and use the same clock-derived counter, they independently compute the identical code without ever communicating.
The Problem: A Code That Works Without Talking to a Server
When you scan a QR code to set up Google Authenticator, Authy, or any similar app, the app and the server agree on one thing at that moment and never need to communicate again for the code to keep working: a shared secret, a random string of bytes typically encoded as base32 text so it fits cleanly into a QR code and can be typed by hand if needed. From that point on, your phone can be in airplane mode in a basement with zero signal, and it will still produce the correct 6-digit code, because generating a TOTP code requires no network round trip at all — just the secret, the current time, and some math both sides already know how to do.
That last part is the whole trick worth understanding: your phone and the server never exchange the code itself. They both independently compute it from the same two inputs — the shared secret and the current time — and because a cryptographic hash function always produces the same output for the same input, both sides arrive at the identical 6 digits without ever needing to ask each other what the answer is. This post is the algorithm underneath that, not advice on whether TOTP is the 2FA method you should pick.
HOTP First: The Counter-Based Algorithm TOTP Is Built On
TOTP (RFC 6238) isn't a standalone invention — it's a specific, small extension of an earlier algorithm called HOTP, HMAC-based One-Time Password (RFC 4226). HOTP takes a secret key and an integer counter, and produces a short numeric code as HOTP(K, C) = Truncate(HMAC-SHA1(K, C)). Concretely: pack the counter into an 8-byte big-endian integer, run it through HMAC-SHA1 keyed with the secret to get a 20-byte digest, then apply what RFC 4226 calls dynamic truncation to squeeze that 20-byte digest down into a plain 6-digit number a human can type.
Dynamic truncation works like this: take the low 4 bits (the low nibble) of the digest's very last byte, and use that as an offset (0-15) into the digest. Read 4 bytes starting at that offset, mask off the top bit of the first of those 4 bytes (this keeps the result a positive 31-bit number regardless of programming language sign conventions), and you get a 31-bit integer. Finally, take that integer modulo 10^6 to get a 6-digit result, zero-padding on the left if needed (a code of 4207 is displayed as 004207, never just 4207).
The original HOTP scheme increments the counter by 1 every time a code is generated (think of an old-school hardware token with a button you press) and both sides have to stay in sync on what counter value they're up to. TOTP's one change is replacing that manually-incremented counter with one derived automatically from the current time, which is what lets two devices that have never talked to each other agree on the same counter value without any synchronization message ever being sent.
The Time Step: Turning "Right Now" Into HOTP's Counter
RFC 6238 defines the TOTP counter as T = floor((Current Unix time - T0) / X), where T0 is a starting epoch (almost always just 0, the Unix epoch) and X is the time step in seconds — the RFC recommends, and virtually every real implementation uses, 30 seconds. That means the current Unix timestamp gets divided by 30 and rounded down, so every timestamp within the same 30-second window produces the exact same counter value, and therefore the exact same code. Your phone and the server don't need to agree on the precise second — they only need to agree on which 30-second window it currently is, which is a much easier bar to clear.
Thirty seconds is a deliberate compromise, not an arbitrary default. Make the window too short and ordinary clock drift between a phone and a server (even a few seconds of drift is common) starts causing legitimate codes to be rejected as expired. Make it too long and you widen the practical window during which a leaked or intercepted code stays usable by someone else. Thirty seconds gives a human enough time to read a code off their phone and type it into a login form, while keeping the exposure window for a stolen code short.
Implementing RFC 6238 From Scratch, No Libraries
Here's the complete algorithm, using only Python's standard library — hmac, hashlib, struct, and base64, nothing else:
import hmac, hashlib, struct, base64
def base32_secret_to_bytes(secret):
secret = secret.replace(" ", "").upper()
return base64.b32decode(secret)
def hotp(key_bytes, counter, digits=6):
# RFC 4226: HOTP(K, C) = Truncate(HMAC-SHA1(K, C))
msg = struct.pack(">Q", counter) # 8-byte big-endian counter
h = hmac.new(key_bytes, msg, hashlib.sha1).digest() # 20-byte HMAC-SHA1 digest
offset = h[-1] & 0x0F # dynamic truncation: low nibble of last byte
truncated = (
((h[offset] & 0x7F) << 24)
| ((h[offset + 1] & 0xFF) << 16)
| ((h[offset + 2] & 0xFF) << 8)
| (h[offset + 3] & 0xFF)
)
return truncated % (10 ** digits)
def totp(secret_b32, for_time, step=30, digits=6):
key_bytes = base32_secret_to_bytes(secret_b32)
counter = int(for_time // step) # RFC 6238: T = floor((unix_time - T0) / X)
code = hotp(key_bytes, counter, digits)
return str(code).zfill(digits), counterLet's run it against a real base32 secret and a specific, fixed Unix timestamp, and then cross-check the result against pyotp, a widely used, independently-written TOTP library, using the identical secret and timestamp:
SECRET = "JBSWY3DPEHPK3PXP"
TEST_TIME = 1893456000 # a fixed real Unix timestamp
code, counter = totp(SECRET, TEST_TIME)
print(code, counter)
# --- real output ---
098407 63115200
# Cross-check against pyotp for the identical secret and timestamp
import pyotp
print(pyotp.TOTP(SECRET).at(TEST_TIME))
# --- real output ---
098407 # <-- identical to our from-scratch resultBoth produce 098407. That's not a coincidence and it's not us tuning the code to match — it's two completely independent implementations of the same RFC 6238 math landing on the identical answer, which is exactly the kind of concrete proof that the algorithm described above is actually correct rather than an approximation of it. We also tested a few more timestamps, including a pair that straddles a 30-second boundary to check that the counter math is truly doing floor-division and not something subtly off-by-one:
t=1727654321 counter=57588477 ours=565541 pyotp=565541 match=True
t=1727654340 counter=57588478 ours=746361 pyotp=746361 match=True
t=1727654341 counter=57588478 ours=746361 pyotp=746361 match=True <- same 30s window as above, same code
t=0 counter=0 ours=282760 pyotp=282760 match=TrueNotice t=1727654340 and t=1727654341 land in the same counter (57588478) and produce the identical code, exactly as the 30-second window model predicts — one second into a new window still belongs to that window, and won't change the code for another 29 seconds.
Why Clock Drift Matters, and How Servers Handle It
Because both sides compute the counter purely from their own local clock, TOTP's entire correctness depends on the phone's clock and the server's clock actually agreeing on roughly what time it is. A phone whose clock has drifted by, say, 40 seconds will compute a different counter value than the server expects, and produce a code the server considers wrong even though the secret and the algorithm are both fine. This is the single most common real-world cause of "my authenticator code isn't working" support tickets, and it's specifically why authenticator apps periodically sync their internal clock against network time servers rather than trusting the device's own clock blindly.
Server-side implementations compensate by checking not just the current counter value, but usually one step behind and one step ahead as well (counter-1, counter, counter+1) — effectively accepting a code that's valid for the previous, current, or next 30-second window, which absorbs roughly ±30 seconds of drift without weakening security in any way that matters in practice, since the accepted window is still only about 90 seconds wide. Accepting a much wider window than that starts trading real security margin for convenience, which is why implementations don't just extend the acceptance window indefinitely to "fix" drift problems.
