OTP Cryptography Internals · Chapter 3 of 5
TOTP: time steps, clock skew and drift windows
Turn wall-clock time into a counter, then decide how much disagreement between two clocks your verifier should forgive.
TOTP is HOTP with the counter replaced by the clock. RFC 6238 defines the moving factor as T = floor((now - T0) / X), where now is Unix time in seconds, T0 is the epoch offset (zero in practice) and X is the step size (thirty seconds by default). Everything from the previous chapter applies unchanged: T is serialised as eight big-endian bytes, fed into HMAC, truncated and reduced. The engineering questions shift from counters that drift to clocks that disagree.
The beauty of this substitution is that neither side has to remember anything to stay in sync. Two devices with accurate clocks compute the same T without communicating, and the code changes on its own every step. The cost is that every verifier decision now involves time, and time on consumer devices is less reliable than engineers like to assume.
Step size: why thirty seconds
The step is a usability and security dial. A shorter step shrinks the replay window but forces users to race the countdown; a longer step is comfortable but leaves an observed code valid for longer. Thirty seconds was chosen as the point where a person can read a code, switch apps and type it before it rolls over most of the time. Some banking tokens use sixty seconds; a few internal tools use fifteen. Changing the step changes every code, so it must be recorded alongside the secret and conveyed in the provisioning URI.
Note that the user does not get thirty seconds from the moment they look at the code. They get whatever is left of the current step. On average that is fifteen seconds, and some fraction of users will always open the app with two seconds remaining. This is why every serious verifier accepts at least the previous step, and why many authenticator apps show a countdown ring.
Skew versus drift
Skew is the instantaneous difference between the authenticator’s clock and the server’s. Drift is how that difference changes over time. Phones sync to network time and rarely skew by more than a second or two. Hardware tokens have no network: their crystal oscillators drift by a few seconds per month, and after two years a token can be a minute or more off. Desktop authenticators running in virtual machines that were paused and resumed can be wildly off until they resync.
Your server clock matters just as much. A verifier running on a host whose NTP daemon died silently will slowly reject an increasing share of valid codes, and the failure looks exactly like users mistyping. Monitor clock offset on verifier hosts as a first-class health signal, and alert well before it approaches half a step.
Choosing a window
RFC 6238 recommends accepting at most one step backward to absorb transmission delay, and allows a larger window for resynchronisation. The common production setting is ±1: compute codes for T−1, T and T+1 and accept a match on any of them. That gives roughly ninety seconds during which a given code is valid, which is the real replay window you must defend with the cache from chapter 5.
±0: strict; expect complaints from users near step boundaries.−1/+0: tolerates the user who reads late but rejects fast clocks.±1: the pragmatic default; three HMACs per verification.±2or wider: only for hardware tokens with tracked drift, never as a blanket setting.
Every additional step multiplies the guessing surface. With a ±1 window there are three valid codes at any moment, so a single random guess succeeds with probability three in a million rather than one. That is still tiny, but it compounds with attempt limits: if you allow ten attempts per account per window, the per-window success probability for a blind attacker is about thirty in a million. Attempt limits must be set with the window in mind.
Learning each user’s drift
A better approach than a wide global window is to record, for each authenticator, the offset at which its last successful code matched. If Bilal Rezaei’s hardware token consistently matches at T−2, the verifier can centre its ±1 window on T−2 for him, keeping three candidate codes while following his clock. Update the stored offset only on success, cap how far it may move in one update, and reset it if the user re-enrols. This is the TOTP analogue of HOTP counter resynchronisation.
type TotpState = { secret: Uint8Array; driftSteps: number; lastStep: number };
function candidateSteps(nowSec: number, s: TotpState, step = 30) {
const t = Math.floor(nowSec / step) + s.driftSteps;
return [t - 1, t, t + 1].filter((x) => x > s.lastStep); // never re-accept
}Edge cases worth a test
Leap seconds do not affect TOTP because Unix time ignores them. Daylight-saving changes do not affect it either, since only UTC epoch seconds are involved; bugs in this area almost always come from code that converts through a local time zone by mistake. The year 2038 problem is real for implementations that store T in a signed 32-bit integer, which is one more reason to follow the RFC’s eight-byte counter. Finally, test what happens at exact step boundaries: at 1,790,000,010 seconds (a fresh timestamp from our bench) the step is 59,666,667 with ten seconds of window remaining; one second before, it is still the same step.
RFC 6238 Appendix B ships test vectors for SHA-1, SHA-256 and SHA-512 using eight-digit codes. With the ASCII key 12345678901234567890 and SHA-1, time 59 gives 94287082 and time 1111111109 gives 07081804. Add them to the same CI suite as the HOTP vectors.
// bench check
0/3 correct · 0 answered
1. With a ±1 window and 30 s steps, roughly how long can an observed code be replayed if the verifier keeps no state?
2. A hardware token consistently matches at T−2. The best response is…
3. Which change does NOT alter the TOTP moving factor?