Security Engineering · Authentication
TOTP Time Steps, Drift Windows, and Code Replay
How a shared secret and a counter quantised to thirty seconds produce a six-digit code, why the verifier searches a window, and what makes a used code reusable.
A one-time password is a MAC output truncated to digits. The verifier and the client share a secret; both derive the same counter value from the current time; both compute the same digest; both display or accept the same six digits. Everything interesting about the scheme lives in the truncation, the counter derivation, and the fact that nothing in the code itself binds it to a single use.
From counter to digits
HOTP, defined in RFC 4226, is the base construction. The shared secret $K$ and a counter $C$ are inputs to HMAC, producing a 20-byte value for SHA-1:
$$H = \mathrm{HMAC\text{-}SHA1}(K, C_{64})$$
where $C_{64}$ is the counter encoded as a big-endian 64-bit integer. The digest is then truncated by a rule that depends on its own content: take the low four bits of the last byte as an offset $o$ (0 through 15), extract the four bytes starting at $o$, and mask off the top bit so the result is a non-negative 31-bit integer. Reducing that modulo $10^d$ yields the $d$-digit code, with leading zeros preserved by formatting.
The dynamic truncation is the part worth understanding rather than memorising. Using digest bits to choose the offset means the selection is unpredictable to anyone who does not know the digest, so an attacker cannot target a subset of the digest. The cost is that the same digest maps to different code digits depending on its own tail, which is why implementations must follow the offset rule exactly; a plausible-looking alternative such as taking the first four bytes produces a code that never matches.
TOTP, RFC 6238, replaces the counter with a time-derived value:
$$T = \left\lfloor \frac{\text{unix time} - T_0}{\text{step}} \right\rfloor$$
The step is 30 seconds in the default configuration and $T_0$ is zero, so the counter is the number of complete 30-second intervals since the epoch. The rest of the computation is HOTP’s. The digest algorithm is negotiable in the RFC, with SHA-1, SHA-256, and SHA-512 all specified, and the secret length differs accordingly: 20 bytes for SHA-1, 32 for SHA-256, 64 for SHA-512. Six digits and 30 seconds are the interoperable defaults, and they are what authenticator applications implement without configuration.
The choice of 30 seconds is a compromise between two failure modes. Shorter steps make the window in which a captured code is usable smaller but increase the chance that a code expires between display and entry. Longer steps reduce false rejections and widen the replay window. Thirty seconds with six digits gives a code space of $10^6$ and 120 valid codes per hour, one for each step, which sets the guessing probability for a single attempt at one in a million.
Why the verifier searches a window
Client clocks drift. A phone that has been offline for a week may be seconds off; a device with a wrong time zone configured is usually right on the absolute time and wrong on the display; a server whose clock is synced to a slightly different reference may be a second or two off. If the verifier accepted only the exact current counter, a client two seconds behind the boundary would fail on roughly one attempt in fifteen, which is enough to make the factor feel broken.
The standard remedy is to accept codes for a range of counters around the current one, typically one step before and one after. That triples the number of codes that verify at any instant, which triples the attacker’s chance per guess from one in a million to three in a million, and it costs the verifier three HMAC computations instead of one. The asymmetry is deliberate: the cost to the verifier is trivial and the cost to the attacker is a factor of three on an already-small probability.
The window must be symmetric or it must be documented. A verifier that accepts only the current and previous steps handles slow clients and rejects fast ones; a verifier that accepts the current and next handles the opposite. Most implementations accept a symmetric window and most clients are within a second or two, so the practical difference is small. The value of stating the policy is that the failure mode is diagnosable: a user whose code always fails is either far outside the window or has a clock that is wrong by minutes, and the two need different responses.
Widening the window is the wrong fix for chronic drift. A window of ten steps accepts a code for five minutes, which turns a thirty-second credential into a five-minute one and multiplies the guess probability by twenty-one. The right fix for a badly drifted client is to correct its clock, and the right fix for a server whose own clock is unreliable is to fix the server.
The replay problem
A TOTP code is a function of the secret and the time step. It is not a function of the attempt. Two clients holding the same secret compute the same code at the same moment, and the same code verifies on any number of attempts within its window unless the verifier tracks what it has accepted.
RFC 6238 addresses this directly: the verifier must not accept the same code twice. The mechanism is small. Alongside the shared secret, the server stores the counter value of the last successfully used code. When a code arrives, the server computes the candidate counters within the window, finds which one produces a matching code, and checks that the matched counter is strictly greater than the last-used value. If it is, the code is accepted and the stored value is updated to the matched counter. If it is not, the code is a replay and is rejected.
The ordering matters. A verifier that records only “a code was used in this window” rejects a legitimate user whose second attempt lands in the same step, which happens whenever a user mistypes once and retries quickly. A verifier that records the counter allows a code from a later step to be used while still rejecting the exact value that was already consumed. Since a code is only valid for the step it was derived from, tracking the counter is equivalent to tracking the code and is cheaper.
The counter must be stored per user, not per session, and it must be updated atomically with the authentication decision. Two concurrent requests presenting the same code must not both succeed, which means the check-and-update has to be a single operation in the store, not a read followed by a write. A race here converts the one-time property into a window of a few milliseconds, which is small but not zero.
The last_used_counter also has to survive a restart and has to be replicated with the user record. A verifier that keeps it in memory and loses it on deploy reopens the replay window for the codes that were used just before the restart, which is a narrow but real exposure.
import hmac, hashlib, struct, time
def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
msg = struct.pack(">Q", counter)
digest = hmac.new(secret, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F # low nibble of the last byte
value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return str(value % (10 ** digits)).zfill(digits)
def verify(secret: bytes, code: str, last_counter: int, step: int = 30, skew: int = 1):
current = int(time.time()) // step
for counter in range(current - skew, current + skew + 1):
if counter > last_counter and hmac.compare_digest(hotp(secret, counter), code):
return counter # caller must persist this atomically
return None
The compare_digest call is the constant-time comparison discussed in credential comparison. It matters less here than for a long-lived secret, because the code space is only $10^6$ and an attacker who can measure timing precisely enough to extract digits one at a time would need many attempts inside a thirty-second window. It costs nothing and removes the question.
Distribution of the secret, and the enrolment step
The security of the scheme rests entirely on the shared secret. It is generated by the server, typically 160 bits for SHA-1, and delivered to the client once, at enrolment, usually as a QR code containing a URI of the otpauth form with the issuer, the account name, the secret in base32, and the algorithm, digits, and period parameters. The base32 encoding is a convention inherited from the original specification and is not a security property; it exists because it survives transcription and is easy to type.
That delivery is the weak point. The secret is displayed on screen and encoded in a QR code, both of which are readable by anything with access to the display at that moment. A screen-sharing session during enrolment, a screenshot, or a camera pointed at the monitor hands the attacker the long-term secret, after which every future code is predictable and the factor provides nothing. The mitigation is to require the first code to be entered before enrolment is considered complete, which proves the client received the secret and simultaneously consumes the step, and to treat the enrolment window as short-lived and single-use.
The secret must be stored encrypted at rest, because it is equivalent to a permanent password for the second factor. An attacker who reads the secret table can generate valid codes forever, and no amount of rate limiting on the verification endpoint helps, since the codes will be correct.
Recovery is the other structural weakness. A user who loses the device has lost the factor, and the recovery path, whether backup codes, an administrator reset, or a fallback to email, becomes the weakest link in the chain. Backup codes are usually a list of random values, each usable once, and they should be stored hashed like passwords, because the list is a set of bearer credentials. A recovery path that bypasses the second factor entirely means the second factor is optional for anyone who can trigger that path, which is the downgrade problem described in factor independence.
What the code does not prove
A valid TOTP code proves possession of a secret at the moment of verification, within the window. It does not prove the request came from the user’s device, because the code can be phished along with the password in real time. An attacker who operates a proxy between the user and the real service can relay the password and the code within the same thirty-second step and obtain a valid session. The counter advances, so the code cannot be reused, but the attacker’s session is already established.
This is the structural limit of any shared-secret OTP. The code is a bearer value that travels through the same channel as the password, and a party that controls that channel obtains it. Closing that gap requires a credential that is bound to the origin of the request and never leaves the client’s control, which is what the WebAuthn ceremony provides. TOTP remains widespread because it needs no special hardware, works offline, and is cheap to deploy, and those properties are worth the residual exposure in many settings. It is a factor that raises the cost of credential theft, not one that defeats a determined relay.