BlogSecurity

How TOTP authenticator codes actually work

How TOTP (RFC 6238) turns a shared secret and the current time into a 6-digit code, why both sides agree, and what it protects against and what it does not.

When an authenticator app shows a 6-digit code and the server accepts it, no network call happened between them. The phone and the server each computed the same number independently from two inputs they both already hold: a shared secret fixed at enrollment, and the current time. That is the entire idea behind TOTP — a second factor that works offline, on an airplane, with the server unreachable, because both sides are running the same deterministic function over the same clock.

TOTP is RFC 6238. It is a thin layer over HOTP (RFC 4226), which is itself a thin layer over HMAC. Understanding it means understanding exactly three things stacked on top of each other.

HOTP: a keyed hash of a counter

HOTP produces a code from a shared secret K and a counter C. The counter is an 8-byte integer. The construction is:

  1. Compute HMAC-SHA1(K, C) — a 20-byte MAC.
  2. Apply dynamic truncation to collapse those 20 bytes into a number.
  3. Reduce that number mod 10^digits to get the displayed code.

Dynamic truncation is the only non-obvious step. The low nibble of the last byte of the HMAC (a value 0–15) is read as an offset. Four bytes are taken from the HMAC starting at that offset, the top bit is masked off to keep the result positive, and the resulting 31-bit integer is reduced mod 10^digits. With the default 6 digits that is mod 1000000, leaving a number from 000000 to 999999.

mac      = HMAC-SHA1(secret, counter)   # 20 bytes
offset   = mac[19] & 0x0f                # last nibble, 0..15
chunk    = mac[offset .. offset+3]       # 4 bytes from that offset
binary   = chunk & 0x7fffffff            # mask the sign bit -> 31 bits
code     = binary % 10^digits            # default digits = 6

The offset is derived from the hash itself, which is why it is called dynamic. It spreads the four sampled bytes across the MAC instead of always reading the same window, so the output reflects the whole digest rather than a fixed slice.

TOTP: replace the counter with time

HOTP increments its counter manually — each press of a hardware token advances C by one, and the server tracks the expected value. That synchronization is fragile, because the two sides drift apart if presses are missed.

TOTP removes the manual counter. Instead of a number the user advances, the counter is the clock:

counter = floor(currentUnixTime / period)    # period defaults to 30
code    = HOTP(secret, counter)

With a 30-second period, the counter changes twice a minute. From 12:00:00 to 12:00:29 it holds one value; at 12:00:30 it ticks to the next. Both the phone and the server read their own clocks, divide by 30, floor it, and feed that integer into the exact HOTP construction above. If their clocks agree to within the window, they compute the same six digits without ever talking to each other.

That is the whole mechanism. TOTP is literally a keyed hash of the current time, truncated to six digits.

Enrollment: the QR code

The shared secret has to reach both sides exactly once, at setup. The standard carrier is an otpauth:// URI, almost always delivered as a QR code:

otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30

The fields:

  • secret — the shared key, Base32-encoded. Base32 because the alphabet survives being typed by hand and shown in a font; the decoded bytes are the K fed to HMAC.
  • issuer and account — labels the app shows ("Example", "[email protected]"). Cosmetic; they do not enter the computation.
  • algorithmSHA1, SHA256, or SHA512. Default SHA1.
  • digits6 or 8. Default 6.
  • period — seconds per step. Default 30.

The defaults are SHA1, 6 digits, 30 seconds, and they are effectively mandatory in practice. The spec permits SHA-256 or SHA-512 and 8 digits, but many popular authenticator apps silently ignore the algorithm and digits parameters and assume the defaults — so a server that issues an 8-digit SHA-256 secret may find half its users' apps generating 6-digit SHA-1 codes that never validate. If you control both ends you can use the stronger options; if users bring their own authenticator, stick to the defaults.

(The HMAC-SHA1 here is not the SHA-1 collision problem. TOTP relies on HMAC, which depends on SHA-1's resistance to a different and far stronger attack than the collisions that broke SHA-1 for signatures. HMAC-SHA1 is still considered safe. It is the same keyed-hash primitive used to authenticate webhook signatures — TOTP is that construction with the message being the current time.)

Clock skew and the validation window

Clocks drift. A phone whose time is 40 seconds fast will compute the next step's code while the server is still on the current one. If the server only accepted the code for its own current step, those users would fail every login.

So servers accept a window. The common choice is ±1 step: the server checks the current counter, the previous one, and the next one, and accepts a match against any of the three. With a 30-second period that tolerates roughly ±30 seconds of skew while keeping the number of valid codes at any instant to three.

Widening the window is a direct trade-off:

  • Wider — fewer legitimate failures from drift, but more codes are valid simultaneously, so an attacker guessing blindly has a larger target and a relayed code stays usable longer.
  • Narrower — tighter security, but users with sloppy clocks get locked out.

±1 is the usual balance. Servers that see chronic drift sometimes record each user's measured offset and shift their window rather than widening it for everyone.

What TOTP protects against, and what it doesn't

TOTP defends one specific thing: an attacker who has stolen a password still cannot log in without also having the code, and the code changes every 30 seconds. Against credential stuffing and database leaks that is a real, large improvement. It is the natural second factor behind a strong first-factor password.

The limits are equally specific:

  • It is phishable. The code is a short string the user reads and types. A real-time phishing site can prompt for it and relay it to the genuine server within the same window. Nothing in TOTP binds the code to the site requesting it — unlike WebAuthn/passkeys, where the credential is cryptographically scoped to the origin and cannot be replayed elsewhere. If you need phishing resistance, TOTP is not it.
  • Replay within the window. A code is valid for the whole step (and the accepted neighbors). Servers should mark a code as used the moment it succeeds and reject the same code for the rest of its window, or the relay above buys the attacker tens of seconds.
  • Secret-at-rest risk. The server stores the same secret the phone does. A database breach that exposes TOTP secrets lets the attacker generate every victim's codes. These secrets need encryption at rest and the same handling discipline as password hashes — arguably more, since they are reversible to working codes.

Even with those caveats, TOTP beats SMS one-time codes decisively. SMS is interceptable through SIM swaps and SS7 attacks, depends on carrier delivery, and sends the secret over the network on every login. TOTP exchanges the secret once and never transmits it again. When passkeys are not an option, app-based TOTP is the right default.

The one-line summary

TOTP is HMAC of the clock. Share a secret once, agree on the time, truncate the keyed hash to six digits, and accept a small window for drift. Everything else — the QR code, the Base32, the validation window — is plumbing around that core. To inspect the mechanism directly, paste a Base32 secret into our TOTP Generator and watch the code roll over at each 30-second boundary; it runs the exact construction above in your browser, with nothing sent to a server.