Every time you open an authenticator app and read off a six-digit code, a small but elegant piece of cryptography is running on your phone. That code was not fetched from a server, it was not sent by SMS, and it was not stored anywhere in advance. It was computed on the spot from two ingredients: a secret you and the server both hold, and the current time. This article walks through the entire Time-based One-Time Password (TOTP) mechanism defined in RFC 6238, from the moment you scan the enrolment QR code to the moment the server accepts your code at login. If you want to watch each of these steps run against a secret you supply, our TOTP generator computes codes in your browser and shows the intermediate values.
The Big Picture: A Shared Secret Plus the Clock
TOTP is a symmetric scheme. There is no public key, no certificate, and no network round trip during code generation. Both your authenticator and the service share one secret key, established once at enrolment. From that key, either side can independently derive the same short numeric code for any given moment in time. Because both sides read the same wall clock and hold the same key, they arrive at the same six digits without ever exchanging that code in advance. The code changes every 30 seconds by default, so even if an attacker captures one, it becomes worthless almost immediately.
TOTP is formally an extension of an older algorithm, HOTP (HMAC-based One-Time Password), specified in RFC 4226. HOTP derives a code from a secret and a counter that increments by one each time a code is used. TOTP keeps the entire HOTP construction but replaces that event counter with a value derived from the current time. Understanding this relationship is the key to understanding TOTP: everything HOTP does, TOTP does too, and the only difference is where the counter comes from.
Enrolment: Establishing the Shared Secret
When you turn on two-factor authentication for an account, the server generates a random secret, typically 160 bits (20 bytes) of cryptographically secure randomness. This secret must reach your authenticator app, and the standard way to move it is a QR code. The QR code encodes an otpauth:// URI following the Google Authenticator Key URI format, for example otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30. The full anatomy of that URI and the QR code that carries it is covered in our companion guide, Anatomy of a 2FA QR code.
The secret inside that URI is encoded in base32 as defined by RFC 4648. Base32 uses the 26 uppercase letters A to Z and the digits 2 to 7 — a 32-symbol alphabet where each character represents exactly five bits. Base32 is used instead of the more compact base64 for a practical reason: the alphabet contains no lowercase letters, no easily confused characters, and no symbols, so a person can read a secret aloud or type it by hand without ambiguity when a camera is unavailable. Your app decodes the base32 string back into the original raw bytes and stores them. From this point on, both the app and the server hold identical key bytes, and enrolment is complete. Nothing else needs to be transmitted, ever again.
The Time Step: Turning the Clock Into a Counter
To generate a code, TOTP first converts the current time into an integer counter. It starts from Unix time — the number of seconds elapsed since the epoch, midnight UTC on 1 January 1970 — and divides by the period, the length of the time step in seconds. The period is 30 seconds in almost every real-world deployment. The counter is the floor of that division: counter = floor(unix_seconds / period). So during the 30 seconds from 12:00:00 to 12:00:29 the counter holds one value, and at 12:00:30 it increments by one and holds that new value for the next 30 seconds.
This is why every device involved must agree on the current time and why authenticator apps insist on an accurate clock. The counter is a pure function of the clock: if your phone's time drifts by 45 seconds, it will compute the counter for a different step than the server and produce a code the server does not expect. RFC 6238 also allows a start offset called T0, which defaults to zero (the Unix epoch), so in practice the formula is simply the elapsed Unix seconds divided by the period. The resulting counter is then formatted as an 8-byte (64-bit) big-endian integer, which is the exact input format the underlying HOTP algorithm expects. That 8-byte block is the only thing that changes from one time step to the next — the key stays constant.
The HMAC: Mixing Secret and Counter
With the 8-byte counter in hand, TOTP computes a keyed hash. It runs HMAC (Hash-based Message Authentication Code) using the shared secret as the key and the 8-byte counter as the message. The default hash function is SHA-1, which produces a 20-byte (160-bit) output. RFC 6238 explicitly permits SHA-256 and SHA-512 as alternatives, producing 32-byte and 64-byte outputs respectively, and the algorithm parameter in the enrolment URI records which one is in use.
It is worth addressing a common worry: SHA-1 is broken for collision resistance, so why is it acceptable here? The answer is that TOTP does not rely on SHA-1's collision resistance at all. It relies on HMAC-SHA-1's property as a keyed pseudorandom function, and no practical attack against HMAC-SHA-1 in that role is known. An attacker who does not hold the secret key cannot predict the HMAC output for any counter. The output of this step is an unpredictable 20-byte string that depends jointly on the secret and the exact time step. But a 20-byte string is not a human-friendly login code, so one more step compresses it down.

Dynamic Truncation: From 20 Bytes to Six Digits
The final stage is the clever part inherited directly from RFC 4226, called dynamic truncation. Its job is to deterministically pick a few bytes out of the 20-byte HMAC and turn them into a short decimal number, in a way that no attacker can bias. The procedure is precise and worth following byte by byte.
- Look at the last byte of the HMAC output (byte 19 for SHA-1). Take its low four bits — its low nibble — by computing
lastByte & 0x0F. This yields a value from 0 to 15. Call it the offset. - Starting at that offset, read four consecutive bytes from the HMAC output. Because the offset can be at most 15 and you read four bytes, the highest byte you can touch is index 18 — still safely inside a 20-byte array. This is why the offset is drawn from the last byte and masked to a nibble: it guarantees the four-byte window always fits.
- Assemble those four bytes into a 31-bit integer. The topmost bit is masked off with
& 0x7FFFFFFFto avoid any sign-bit confusion across programming languages that treat the leading bit as negative. The result is a positive integer between 0 and just over 2.1 billion. - Reduce that integer to the desired number of digits with a modulo:
value % 10^digits. For the standard six-digit code that isvalue % 1000000, giving a number from 0 to 999999. - Left-pad the number with zeros to the full digit count. A computed value of 42 becomes the displayed code
000042. This padding matters: dropping leading zeros would produce a code of the wrong length that the server rejects.
The reason for choosing the offset dynamically — rather than always reading, say, the first four bytes — is to spread which part of the HMAC output determines the code. It ensures every byte of the hash can influence the result across different inputs, removing any fixed structure an attacker might otherwise exploit. The whole truncation is deterministic: given the same HMAC output, it always yields the same code, which is exactly what lets two independent devices agree.
What the Server Stores and How It Verifies
A crucial and often misunderstood point: the server does not store a list of valid codes, and it does not store the code you are about to type. It stores only the shared secret and the parameters — the algorithm, the number of digits, and the period. When you submit a code at login, the server performs exactly the same computation your app just did: it reads its own clock, computes the current counter, runs HMAC with the stored secret, applies dynamic truncation, and compares the result to what you typed. If they match, you are authenticated. The code itself is never transmitted from server to client and never persisted; it exists only for the instant of comparison.
This design has an important security consequence. Because the secret is symmetric, anyone who reads the server's secret store can generate valid codes forever, which is why TOTP secrets must be encrypted at rest and treated with the same care as password hashes. It also means TOTP, unlike a hardware security key, does not resist phishing on its own: a convincing fake login page can capture your six digits and replay them to the real server within the 30-second window. That trade-off, and how newer methods address it, is the subject of our comparison, TOTP vs SMS vs passkeys.
Clock Drift and Validation Windows
In the real world, clocks are never perfectly synchronised. Your phone might be a few seconds fast, the server a few seconds slow, or you might take a moment to type the code and cross a step boundary mid-entry. If the server accepted only the single code for its exact current counter, TOTP would fail constantly and infuriate users. RFC 6238 addresses this by recommending a validation window. Instead of checking one counter, the server also checks the immediately adjacent steps.
The standard practice is to accept the current step plus or minus one — that is, it computes the codes for counter − 1, counter, and counter + 1 and accepts your input if it matches any of the three. With a 30-second period, this ±1 tolerance gives an effective acceptance window of up to 90 seconds, absorbing modest drift and typing delay without noticeably weakening security. The RFC notes the trade-off plainly: each additional step the server accepts widens the window an attacker has to guess or replay a code, so servers keep the window small. Some servers implement resynchronisation, remembering a persistent offset if a particular user's device is consistently a step ahead or behind, so a chronically skewed clock can still authenticate reliably. Well-built servers also enforce that any given code can be used only once within its window, preventing an attacker from immediately replaying a code they observed.
Why Both Sides Must Agree on Every Parameter
The code that comes out of TOTP is exquisitely sensitive to its inputs. Change the hash algorithm, the digit count, or the period, and every code changes completely. This is why the enrolment URI carries algorithm, digits, and period explicitly, and why the app and server must both honour those exact values. If a server issues a QR code configured for SHA-256 and 8 digits over a 60-second period, but the app silently assumes the SHA-1, 6-digit, 30-second defaults, the two will compute entirely different codes and every login attempt will fail — even though the shared secret is identical on both sides.
In practice, the overwhelming majority of services use the classic defaults: SHA-1, six digits, and a 30-second period. Many popular authenticator apps historically ignored the non-default algorithm and digits parameters entirely, which is one reason those defaults have proven so sticky — a service that strays from them risks incompatibility with the apps its users already have installed. When you build or debug a TOTP integration, the first thing to confirm when codes do not match is that the algorithm, digit count, and period are identical on both ends, and the second thing to confirm is that the clocks agree. These two checks resolve the large majority of TOTP problems.
Putting It All Together
The complete life of a login code, then, is short and entirely deterministic. At enrolment, a random secret is generated, base32-encoded, and moved into your app by QR code. At login time, both your app and the server take the current Unix time, divide by 30 and floor it to get a counter, format that counter as eight big-endian bytes, run HMAC-SHA-1 over those bytes keyed by the shared secret, apply dynamic truncation to pull out a 31-bit number, reduce it modulo one million, and zero-pad to six digits. Because the two sides share the secret and read the same clock, they land on the same six digits independently, and a small validation window forgives the inevitable drift between them. No secret code ever crosses the network, and each code expires within seconds. To see these exact steps computed live against a secret of your choosing, open the TOTP generator and watch the counter, HMAC, and truncated code update in real time.