Once an authenticator code verifies, what must the server record so the same code cannot be reused?
answer
- the algorithm has no memory
- valid for the whole step, and beyond
- a record you keep, not a property
- monotonic step counter per enrolment
- conditional write, not read-then-write
basics
~20 sRecord which time step the submission matched, on the enrolment row, and refuse anything matching that step or earlier. The code stays valid for the rest of its step; only that record stops a second use.
solid answer
~50 sAccepting a code does not stop it working. The computation is a pure function of the shared secret and the clock, so the same digits keep verifying for the remainder of their step and for as long as the drift window still reaches that step. RFC 6238 Section 5.2 therefore makes one-time use a requirement on the *verifier*: it MUST NOT accept a second use of a code it has already validated. That is bookkeeping you keep, not a property of the algorithm. The cheap form is a monotonic counter: store the highest step yet accepted for that enrolment, and refuse any submission whose matched step is not strictly higher. It has to be claimed with a conditional write rather than a read followed by a write, and it has to live somewhere every application server can see - a per-process set simply moves the replay to another instance.
code
pseudocode · 16 linese = enrolments.active_for(holder)
step = match_step(decrypt(e.secret_ct), submitted, now(), WINDOW, e.offset_steps)
if step == NO_MATCH:
fail("that code did not match")
# claim the step - conditional write, never read-then-write
rows = enrolments.update(
set = { last_accepted_step: step,
offset_steps: step - floor((now() - T0) / STEP) },
where = { id: e.id, last_accepted_step: { "<": step } })
if rows == 0:
fail("that code has already been used") # replay, or a parallel request won
accept_second_factor()go deeper
Know that accepting a code does not stop it working - it stays valid for the rest of its time step - so the server has to write down that it was used and refuse it a second time.
Design the record: a monotonic highest-accepted-step per enrolment, claimed with a conditional update, held where every application server can see it rather than in one process's memory.
Show the race. Explain why read-then-write loses to two simultaneous submissions, what the zero-rows branch means, and what else the same write can usefully carry, such as the enrolment's drift offset.
Separate the controls and say which is load-bearing: one-time use bounds an observed code, the window and the attempt cap bound a guessed one. Removing one does not degrade the other gracefully.
## Accepting a code does not expire it The computation has no memory. `HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))` with `C` derived from the clock will return the same digits for the whole of that step, whether or not the server has already accepted them, and the drift window extends that reach further still. Nothing in the algorithm knows a code has been used. So the exposure is real and short-lived. In a livestock movement-licensing service the digits get observed constantly: read over a shoulder at a market pen, typed on a handset passed between two people at a loading ramp, screenshotted into a message to a support desk because the holder cannot work out which entry in the app is the right one. Whoever sees the digits has the rest of the step - and, if the window is wide, a little more - to submit them somewhere else. RFC 6238 Section 5.2 closes this by putting the obligation on the verifier: it MUST NOT accept a second attempt of a one-time password after a successful validation has already been issued for it. This is one of the specification's few MUST NOTs, and it is a requirement about the server's records rather than about arithmetic. ## One-time use is bookkeeping, not arithmetic The practical consequence is that you must write something down. Two shapes are common, and one is clearly better. - **A set of recently accepted codes.** Works, but stores live credentials in a lookup structure, needs an entry per verification, needs a TTL that outlives the widest accepted step, and needs a lookup per attempt. - **A monotonic step counter per enrolment.** Store the highest time step yet accepted on the enrolment row. A submission is accepted only if the step it matched is strictly greater. One integer, no TTL to reason about, and it rejects the earlier steps still inside the drift window for free. The counter is the better default. It stores no credential, it is correct forever rather than for a retention period, and the comparison is a single inequality. | | recent-code set | monotonic step counter | |---|---|---| | what is stored | accepted code values | one integer per enrolment | | does it store a live credential | yes, until it expires | no | | retention to reason about | a TTL wider than the window | none | | rejects earlier in-window steps | only if they were used | always | | cost per verification | a lookup and an insert | one conditional update | ## Where the record has to live A movement-licensing service runs more than one application server, and a replay does not care which one it lands on. If the used-code record is a set in one process's memory, the second submission simply goes to the other instance and succeeds. Every deployment topology makes this worse rather than better: more instances, rolling restarts that wipe the set, an autoscaler adding a fresh one with no history. The record must therefore live where every verifier can see it - on the enrolment row in the relational database, or in a store all the application servers share. Whichever it is, the property that matters is not durability for its own sake but **visibility to every verifier at once**. ## Claiming the step without a race The obvious implementation is wrong. Reading the stored step, comparing it, and then writing the new one is a read-then-write, and two submissions of the same code arriving together will both read the old value, both pass the comparison, and both write. That is precisely the replay the rule exists to stop, and it happens without an attacker - a double-tapped submit button is enough. The claim has to be conditional: 1. Match the submission against the candidate steps in the window, and note which step matched. 2. Issue an update that sets the enrolment's last accepted step to that value **only where the stored value is strictly lower**. 3. Treat an update that affected zero rows as a rejection: either the code was already used, or a parallel request won the race. 4. Only after a successful claim, continue with the sign-in. The database serialises the two updates, one of them affects no rows, and exactly one submission wins. No lock is held across the request and no coordination is needed beyond the single write. ## What this does not cover One-time use stops the *same* code being spent twice. It does nothing about a *different* code being guessed, which is what the drift window and the verification attempt cap deal with between them - and it says nothing about how the observed code was obtained. It is one control of several, and the honest way to describe it is as the one that makes an observed code worth only what remains of one submission.
- Why record the matched step rather than the digits themselves?Because the digits are a live credential for the rest of their step, so a table of accepted codes is a table of working second factors with a retention period attached. A step number is one integer on the enrolment row, needs no TTL, is monotonic, and rejects the earlier steps still inside the drift window without any extra rule.
- Two requests carrying the same code arrive at two application servers at once. What stops both succeeding?A conditional write: update the enrolment row setting the last accepted step only where the stored value is strictly lower, and treat an update affecting no rows as a rejection. The database serialises them, so exactly one wins. Reading the value and then writing it back has the same race with extra steps in it.
- Does the used-code record need to outlive the sign-in?It needs to outlive the widest step the window still accepts, which is seconds to minutes. Keeping it as a column on the enrolment row rather than as an expiring entry means there is no retention to reason about at all, and the monotonic comparison stays correct indefinitely rather than until a TTL was tuned wrongly.
The number printed on a rail ticket does not change when the conductor punches it; the punch is a mark the railway makes and keeps. One-time use of an authenticator code works the same way - the digits stay perfectly valid for the rest of their step, and it is your record of the punch, visible at every gate rather than at one, that stops the second use.
saying these in an interview costs you the question
- Thinks a code stops working the moment it is accepted
- Keeps the used-code record in one process's memory
- Stores accepted codes in clear to compare later submissions against
- Reads the last accepted step and then writes it back unconditionally
- Assumes a narrow drift window makes replay impossible
- Treats one-time use as something the algorithm guarantees