A donor portal leaves every sign-in code it has sent valid until expiry. What does that cost the attempt cap?
answer
- count what is simultaneously valid
- guesses times accepted values
- one live code per pending attempt
- a reissue supersedes, never accumulates
basics
~20 sEvery simultaneously valid code multiplies an attacker's odds per guess. A cap of five guesses against one live six-digit code is about five chances in a million; twelve live codes make the same cap worth sixty in a million.
solid answer
~40 sThe cap bounds guesses, not accepted values, so the two numbers multiply. With one live code the odds of a blind guess landing are roughly `cap / 1,000,000`; with twelve codes still accepted they are roughly `12 * cap / 1,000,000`, and a caller who can trigger sends controls that multiplier. So **how many codes may be outstanding is a security parameter**, not a usability preference, and the answer is one. That one code is bound to the **pending sign-in attempt** rather than to the donor's account: reissuing overwrites the digest on the same record, so the previous code stops verifying, and the guess count lives and dies with the attempt instead of floating free where a second attempt could reuse either.
code
pseudocode · 12 linesspace = 1_000_000 # a six-digit delivered code
cap = 5 # guesses allowed on one pending attempt
# one live code, bound to this pending attempt
live = 1
odds = cap * live / space # 0.000005
# same cap, but every code sent in the window still verifies
live = 12 # twelve sends triggered by the caller
odds = cap * live / space # 0.00006 -- twelvefold, for free
# the multiplier is the only term the attacker can movego deeper
Remember the shape of the sum: the chance of a blind guess is the guesses allowed times the values accepted, over the size of the code space. Two of those three are design choices.
Design the record so only one code can be live at a time and say what a reissue does to the previous digest and to the expiry. Explain why the live code belongs to a pending attempt rather than to the account.
Show that the multiplier is caller-controlled and say what that does to a cap you presented as the bound. Name what is left standing if the single-live rule is dropped but everything else is kept.
Decide what the service will accept when the rule bites: donors with two open tabs, a delayed message arriving after a reissue, and support calls that follow. Price that against the multiplier you would otherwise be handing out.
## The arithmetic the cap is supposed to buy A six-digit code has a million values. A cap of five guesses on one pending attempt is meant to say: *a caller who never received the code gets five shots at a million, and then nothing.* That statement is only true while **exactly one value is accepted**. The cap bounds how many guesses are submitted; it says nothing about how many values a guess is checked against. Leave every code sent in the last five minutes valid and each guess is now measured against all of them at once: - one live code, cap of five: about **5 in 1,000,000**; - twelve live codes, same cap: about **60 in 1,000,000**; - and the multiplier is chosen by whoever can make the portal send. That last bullet is the part that matters. The attacker does not have to break anything to raise the multiplier — they press the button. A control whose strength is set by the caller is not a control. ## How the multiplier is manufactured A caller holding a donor's password can reach the second step repeatedly, and each arrival is an excuse to issue a code. If codes accumulate, the accepted set grows with every send while the cap stays where it was. The honest way to read the design is as a single fraction: > **odds per attempt ≈ (guesses allowed × values accepted) ÷ (size of the code space)** Three levers, and a portal that only ever tunes the first and third has left the middle one to its attacker. ## One live code, bound to the pending attempt The fix is a rule about the record, not about the digits: **the pending sign-in attempt holds exactly one code digest, and issuing a new code overwrites it.** Not a list, not an array with a cap of three, not the newest plus a grace copy of the previous one. Binding matters as much as counting. Compare the two obvious homes for the live code: | Bound to | What verifies | What goes wrong | |---|---|---| | the donor's account | any code recently sent to that donor, on any attempt | concurrent attempts share an accepted set; the guess count has no natural owner and no natural death | | the pending sign-in attempt | one code, for this attempt only | a code minted for one attempt is useless in another, and the count dies with the attempt that earned it | Account binding is the shape that quietly reintroduces the multiplier even when the portal believes it keeps one code, because *one per account* plus two live attempts is two accepted values, and the donor service cannot see the second attempt on the screen the donor is looking at. ## What supersede means concretely 1. A new code is drawn and delivered. 2. The digest field on the existing attempt record is **overwritten** — the previous code is now dead, immediately, not at its own expiry. 3. `expiresAt` is set from the new issue time, so the window does not accumulate either. 4. The guess count is left exactly where it was, because it counts submissions against this attempt and a reissue is not a submission. Step four is what keeps the whole design from unravelling; it is the subject of its own question. ## Where this stops - **How many bits sit behind the digits**, and which generator draws them, is the randomness subject, not this one. - **Rate-limiting algorithms** and the shared counter state behind them are a separate design; this leaf owns only where the counter lives and what it is keyed to. - **Delivery** — queueing, failover, whether a second send even leaves the building — is designed elsewhere; the rule here holds regardless of how the message travels. ## The failure to be able to name Ask of any one-time-code design: *how many values does the server accept right now, who decides that number, and does the cap assume a different one?* A portal that answers "one, the server, no" is sound. A portal that answers "however many we sent, the caller, yes" has a cap that reads reassuringly in a design document and bounds nothing an attacker cares about.
- A donor triggers six sends in two minutes. How many of those codes should verify?One — the most recent. Each issue overwrites the digest on the same pending-attempt record and resets the expiry from that moment, so the five earlier codes are already dead when the sixth is delivered. Separately, a cooldown on the send action limits how fast that cycle can be driven, and the guess count is untouched by any of it.
- Why bind the live code to the pending attempt rather than to the donor's account?An account-bound code verifies on any attempt, so two concurrent attempts share one accepted set and a code issued for the donor's own attempt finishes somebody else's. Attempt binding also gives the guess count a natural owner and a natural death: it is spent against this attempt and disappears with it, instead of surviving as account state that nobody reaps.
- Does the single-live rule strand a donor who has the screen open twice?It can, and the response must not explain why: a superseded code is answered exactly like a wrong one, or the reply becomes an oracle for a caller who never held a code. The legibility belongs on the screen the donor is already on, which knows it asked for a new code and can say so; the server's answer stays generic.
A lock that accepts any of twelve combinations is not twelve times more convenient; it is twelve times easier to open by guessing. Extra live codes are extra combinations, and the guess budget was sized for one.
saying these in an interview costs you the question
- Says the cap bounds guessing whatever number of codes are live
- Keeps codes valid per account, so any recent code finishes any attempt
- Treats how many codes may be outstanding as a usability preference
- Thinks extra sends are harmless because the attacker sees none of them
- Invalidates older codes only once the newest one is used
- Adds a grace copy of the previous code so a slow donor is not stranded