Skip to content
nviaOTP

OTP Cryptography Internals · Chapter 5 of 5

Verification policy: ±1 windows, resync counters, replay caches

Turn a stateless code check into a verifier that is one-time, rate-limited, drift-aware and safe under concurrency.

6 min read

The first four chapters produced a function that answers one question: does this code match this secret for some acceptable moving factor? A verifier has to answer a different question: should this request be allowed to authenticate right now? The gap between those two questions is policy, and it is where nearly all OTP vulnerabilities in production live. This chapter assembles the policy into a single, ordered procedure.

The order of operations

Order matters because each step protects the next. Rate limiting must happen before any HMAC is computed, or an attacker can use your verifier as an oracle at full speed. The attempt must be recorded before the comparison, or parallel requests can sneak in extra guesses. The consumed step must be written atomically with the success, or two simultaneous requests carrying the same code can both succeed.

The verifier as seven ordered steps. Moving any step later than shown reopens a known attack.

Attempt limits sized to the window

Chapter 3 showed that a ±1 TOTP window means three codes are valid at once. If the attacker may submit N guesses per window, their success probability is about 3N in a million per window. Set limits on two axes: a tight per-account budget (for example five failures before a fifteen-minute lockout) and a looser per-source budget that catches attackers spraying many accounts. Lockouts must not be permanent, or the attempt limit becomes a denial-of-service lever against your own users.

A concrete illustration, labelled illustrative example: with five failures allowed per fifteen minutes and a ±1 window, an attacker sustaining that rate for a full day gets 480 guesses, for a cumulative success probability of roughly 0.14 percent per account. That is why attempt limits are paired with alerting on repeated lockouts, not treated as sufficient alone.

The replay cache

For TOTP, store lastStep: the time step of the most recent accepted code for that authenticator. On verification, discard any candidate step less than or equal to lastStep. On success, write the matched step with a compare-and-set: UPDATE ... SET last_step = :t WHERE id = :id AND last_step < :t. If the update affects zero rows, another request consumed that step first and this one must fail. This single conditional write is what makes the code one-time.

async function verifyTotp(userId: string, code: string, now = Date.now()) {
  if (!(await rateLimit.allow(userId))) return { ok: false, reason: "locked" };
  const s = await store.load(userId);               // secret, driftSteps, lastStep
  await store.recordAttempt(userId);                 // before comparison
  for (const t of candidateSteps(now / 1000, s)) {
    if (timingSafeEqual(await totpAt(s.secret, t), code)) {
      const won = await store.casLastStep(userId, s.lastStep, t, t - Math.floor(now / 30000));
      return won ? { ok: true } : { ok: false, reason: "replayed" };
    }
  }
  return { ok: false, reason: "mismatch" };
}
A verifier skeleton. casLastStep updates lastStep and drift only if lastStep is unchanged since load.

For HOTP the equivalent is the counter itself. Accept a match at any counter in [C, C + s] with a look-ahead s of perhaps ten, then set the stored counter to the matched value plus one with the same compare-and-set. Matches below the stored counter are replays and are rejected without comparison.

Resynchronisation

When a user returns with an HOTP token that has drifted far ahead — say they pressed the button forty times in a drawer — the normal look-ahead will not find it. RFC 4226 suggests a resynchronisation mode: search a much larger range, but only accept if the user supplies two consecutive codes that match consecutive counters. Two codes in a row are a million times harder to guess than one, which justifies the wider search. Offer this through a deliberate, rate-limited flow rather than silently widening the normal window.

For TOTP, drift tracking from chapter 3 is the resync mechanism. A wide-search recovery that requires two codes from consecutive steps is reasonable for hardware tokens; for phone apps, which take network time, a large offset usually means the phone’s clock is wrong and the right fix is to tell the user to enable automatic time.

Concurrency and multi-region

If your verifier runs in several regions with eventually consistent storage, the compare-and-set must go to a single authoritative store or you will accept the same code twice — once per region. Lucía Ferrante, an SRE on our fictional Larkspur Ledger team, reproduced exactly this on 2026-10-02 at 07:31 UTC: two login requests 140 milliseconds apart, routed to different regions, both succeeded with code 418093. The fix was to pin the consume step to a strongly consistent row while keeping reads regional.

What to log

  • Outcome and reason code (ok, mismatch, replayed, locked), never the submitted code.
  • Matched step offset relative to server time, which reveals clock problems early.
  • Lockout events per account and per source, feeding an alert.
  • Enrolment, re-enrolment and recovery events, which are where account takeover usually starts.

With this policy in place, the six digits finally carry the meaning people assume they do: someone holding the enrolled secret produced a fresh code that has not been used before, and they did so within a bounded number of attempts. What the code still cannot tell you is whether the person typing it is on your site or on an attacker’s. That is the problem the next series takes on.

Test the policy as carefully as the cryptography. Useful cases include: the same code submitted twice in sequence, the same code submitted twice concurrently, a code from T−2 when the window is ±1, a correct code arriving while the account is locked, and a correct code arriving just after the lockout expires. Each should produce a clear, documented outcome. Run the concurrent case against your real storage, because in-memory fakes rarely reproduce race conditions.

Finally, decide what success unlocks. A verified code should lead to a session whose strength reflects the factor used. Series 2 develops this into a ladder of assurance levels. For now, record which factor was used and when, so later decisions can tell a session backed by a fresh TOTP from one that is several hours old.

Before
async function verify(user, submitted) {
const T = Math.floor(Date.now() / 30000);
for (let d = -1; d <= 1; d++)
if (totp(user.secret, T + d) === submitted)
return true;
return false;
}
No replay cache → replay-safe verify. Recording the last accepted step makes every code single-use and blocks the ±1 window from accepting an older one.

0/3 correct · 0 answered

  1. 1. Why must the attempt be recorded before comparing the code?

  2. 2. Two requests with the same valid TOTP code arrive 50 ms apart. What guarantees only one succeeds?

  3. 3. HOTP resynchronisation over a wide range is acceptable when…

Discussion

Comments are public and shared with every reader. No account needed — just a display name. Comments reported by three readers are hidden automatically.

15 more characters needed

Loading comments…