A donor presses resend twice on the sign-in code screen. Which counter must the server refuse to reset, and why?
answer
- resend is a server decision
- new code, same guess counter
- the count outlives a restarted flow
- across login sessions, per RFC 4226
- otherwise the cap is decorative
basics
~20 sThe failed-verification count on the pending attempt. A resend may replace the outstanding code and start a fresh expiry, but if it clears the guess count, an attacker alternates resend and guess and the cap bounds nothing.
solid answer
~40 sResend is a **server decision**, not a button: the server chooses that a new code supersedes the outstanding one, that the action has its own cooldown and its own ceiling, and — the rule that must not bend — that it leaves `failedAttempts` exactly where it was. Clearing the count on reissue converts a five-guess cap into an unlimited budget for anyone willing to click between guesses. The same logic extends one step further: the count must also survive the caller throwing the flow away and starting again, which is why it is keyed to the account's second-factor verification in the server's own state rather than to anything the client holds. RFC 4226 Section 7.3 states it as a MUST — the delay or lockout scheme has to work **across login sessions**.
code
pseudocode · 26 lineson_resend(attempt):
if now < attempt.nextResendAllowedAt: return generic_failure()
if attempt.resendCount >= MAX_RESENDS: return generic_failure()
code = draw_code()
attempt.codeDigest = digest(code) # supersedes the previous code
attempt.expiresAt = now + 5m
attempt.nextResendAllowedAt = now + 30s
attempt.resendCount += 1
# attempt.failedAttempts is deliberately NOT touched
deliver(code, attempt.channel)
return generic_ok()
on_verify(attempt, submitted):
if attempt.failedAttempts >= attempt.maxAttempts: return generic_failure()
if account_counter(attempt.donorId).exhausted(): return generic_failure()
if attempt.consumedAt != null
or now > attempt.expiresAt
or digest(submitted) != attempt.codeDigest:
attempt.failedAttempts += 1
account_counter(attempt.donorId).spend() # survives a restarted flow
return generic_failure()
attempt.consumedAt = now
return success()go deeper
Know the one sentence: asking for a new code does not give you new guesses. The count of failed submissions belongs to the server and survives the new code being sent.
Describe resend as three server decisions — supersede, cooldown, ceiling — and show which field each one touches. Be able to say what a restarted sign-in flow does to a counter kept only on the attempt record.
Walk the attacker's loop and say which single control stops it, then say what is left if that control is removed. Price the account-level counter against the lockout it becomes and explain how it releases.
Decide the numbers for the service: cap, cooldown, ceiling, release time, and who is told when a ceiling is hit. Defend the denial of service you are accepting against the guessing budget you would otherwise be giving away.
## Resend is a decision, not a button On the screen, resend is a link a donor presses when nothing has arrived. On the server it is a small set of decisions that have to be made deliberately: - **Does the new code supersede the outstanding one, or join it?** Supersede — one live code per pending attempt. - **How soon may it be pressed again?** A cooldown recorded on the attempt, tens of seconds, enforced server-side. - **How many times in total?** A ceiling per attempt, after which the answer is the same generic failure. - **What does it do to the guess count?** **Nothing.** This is the one that is load-bearing. A resend that clears `failedAttempts` is not a relaxation of the cap; it is the deletion of the cap. The attacker's move is trivial: guess, guess, guess, guess, guess, resend, repeat. Five is now five per click, and the clicks are free. ## The move the counter has to survive There are two versions of the same trick, and a design that stops only the first is still open: 1. **Reissue between guesses.** Handled by keeping the counter untouched when a new code is issued, and by giving the reissue its own cooldown so the loop cannot be driven quickly. 2. **Restart the whole flow.** Abandon the pending attempt, present the first factor again, receive a brand-new attempt record — and with it, if the counter lives only on that record, a brand-new budget. The second is why a per-attempt cap alone is not the throttle. The specification behind event-based one-time codes, RFC 4226, is explicit at Section 7.3: the delay or lockout scheme **MUST be across login sessions**. A counter the caller can discard by starting again is exactly the counter that specification is ruling out. ## Two counters, two jobs | Counter | Keyed to | Size | Job | |---|---|---|---| | per-attempt cap | the pending sign-in attempt | small, a handful | bounds guesses against the one live code, and dies with the attempt | | second-factor verification counter | the account's second factor, in server state | larger, with a cooldown that expires on its own | survives reissue, abandonment and a fresh first factor, so restarting buys nothing | | per-source lane | the calling network source | coarse | catches spraying across many accounts; a supporting lane, never the primary one | The per-source lane is coarse on purpose: a donation service's donors arrive through shared addresses and mobile networks that re-address constantly, so keying the real decision there punishes the wrong people. The algorithms behind any of these counters, and how the state is shared between instances, are a separate design; what belongs here is **where the counter lives and what it is keyed to**, because that is what decides whether a caller can start afresh. ## What the responses may say Wrong code, expired code, and cap reached must be indistinguishable to a caller who never held a code — the same answer, and no side-effect or timing tell that separates them. A portal that says *too many attempts* has confirmed that the previous submissions were being counted against something real, and, more usefully to an attacker, that the account exists and the first factor was accepted. The donor's own screen knows what it asked for and can be as helpful as it likes; the server's answer stays flat. ## The denial of service you are accepting A counter that survives restarts is a counter that can be driven to its limit by whoever holds the first factor, and the donor is then held out. That is a real cost and it is priced, not ignored: - the account-level counter **releases on its own** after a cooldown, so the failure mode is waiting rather than a call to a volunteer coordinator; - the per-attempt cap is small and clears with a fresh sign-in, so an honest mistyped code is cheap to recover from; - reaching the ceiling is worth an alert to the account's own channel, because someone is presenting the first factor. ## Where this stops The throttle's algorithms, the distributed counter behind them and the client-facing contract for a refused request are owned elsewhere, as is the pre-authentication handle the attempt hangs off and the re-issuing of the session identifier once the code finally verifies. What this subject owns is the decision that survives every one of those choices: **a new code is not a new budget.**
- How does a donor who genuinely exhausted the cap get back in?By waiting. The per-attempt cap is small and clears when a fresh sign-in re-proves the first factor; the account-level counter releases on its own after a cooldown rather than needing a human to unlock it. Permanent locks turn a guessing control into a support queue and hand anyone who knows a password a way to keep a donor out indefinitely.
- Should the resend action have its own cooldown, and what is that protecting?Yes, recorded on the attempt and enforced server-side. It protects the donor's inbox or handset from a flood a caller can trigger at will, and it bounds how fast the supersede cycle can be driven so the window of any one code is not being churned continuously. How the messages themselves are queued, failed over or dropped is the delivery design's business.
- Is capping the send endpoint enough on its own?No, and it is a common misplacement. Limiting sends protects the channel and the donor's inbox; it does nothing about a caller who holds one delivered code's window and submits guesses against it. The verification endpoint needs its own counter, keyed so that neither a reissue nor a restarted flow resets it.
An exam with three attempts is an exam with unlimited attempts if asking for a fresh paper also wipes the record of the attempts already made.
saying these in an interview costs you the question
- Clears the failed-guess count whenever a fresh code is sent
- Keeps the count on the caller's handle, so restarting the flow clears it
- Throttles the send endpoint and leaves verification uncapped
- Answers 'too many attempts' distinctly from 'wrong code'
- Treats resend pressure as a usability issue with no security parameter in it
- Locks the account permanently once the second-factor counter is exhausted