A donor portal emails a one-time sign-in code. What does the server write down so it can verify it later?
answer
- server-side state, not the client's
- one record per pending sign-in
- digest, expiry, consumed marker, guess count
- every outcome spends one attempt
basics
~20 sA pending-attempt record on the server: a digest of the delivered code, the sign-in attempt it belongs to, an expiry a few minutes out, a consumed marker, and the count of failed guesses against it.
solid answer
~40 sThe code is not state the client can be trusted to carry. When the first factor succeeds the server creates a **pending sign-in attempt** and hangs the code's state off it: a digest of the code rather than the code itself, the channel and masked destination it went to, `issuedAt` and a short `expiresAt`, a `consumedAt` marker so a correct code works exactly once, and `failedAttempts` against a fixed `maxAttempts`. Verification loads that one record, checks the cap first, spends one attempt whether the submitted value is wrong, expired or already consumed, and only then compares digests. The record is doing the security work — six digits are guessable on their own, and they are defensible only because what surrounds them is short-lived, single-use and capped.
code
json · 14 lines{
"attemptId": "5f3c1a9e-7b02-4d11-9c8a-6f2b0d4e1a77",
"donorId": 90412,
"channel": "email",
"destination": "d***@donorpost.example",
"codeDigest": "sha-256:4b8f2c…",
"issuedAt": "2026-09-19T09:14:02Z",
"expiresAt": "2026-09-19T09:19:02Z",
"consumedAt": null,
"failedAttempts": 1,
"maxAttempts": 5,
"resendCount": 1,
"nextResendAllowedAt": "2026-09-19T09:14:32Z"
}go deeper
Be able to name the row and its fields out loud: what the code belongs to, when it dies, whether it has been used, how many guesses it has absorbed. Saying the server keeps a digest rather than the code is what separates a real answer from a guess.
Explain the lifecycle: created at issue, spent on every failure, marked on success, reaped at expiry. Show where the check sits on every path that accepts a code, not just the screen the feature shipped with.
Argue why an expired and a consumed submission cost the same as a wrong one, and what that uniformity buys against a caller who never held a code. Name which single field, removed, makes the rest decorative.
Price the record itself: how many live attempts a donation service carries at peak, what reaping them costs, and whether the attempt state belongs in the same store as sessions given what an outage of it does to sign-in.
## Why the code cannot be the state A code delivered to a mailbox or a handset is not something the donor holds securely, and it is not something the client can be trusted to carry back. It is a **challenge the server issued**, and the only party that can say whether a submitted value is the right one, whether it is still live, and how many times it has already been guessed is the server. So a successful first factor does not produce a session; it produces a **pending sign-in attempt** — a server-side record that lives for a few minutes and then either completes or dies. Everything the second factor decides hangs off that record. A donor portal that keeps the code in the page, in a cookie, or in a field the client returns alongside its guess has moved the security decision to the side of the wire that is trying to get past it. ## The fields, and what each one is defending | Field | What it holds | What breaks without it | |---|---|---| | attempt id | the pending sign-in this code belongs to | a code minted for one attempt is redeemable in another | | code digest | a digest of the delivered code | anyone who can read the store, a backup or a stray log line can sign in during the code's life | | `expiresAt` | one absolute deadline, minutes rather than hours | the guessing window is bounded only by the donor's patience | | `consumedAt` | the instant a correct code was accepted | a correct code keeps working for the rest of its life | | `failedAttempts` / `maxAttempts` | guesses already spent against this attempt | six digits are guessed at leisure | | channel + masked destination | which address or number it went to | the portal cannot tell the donor where to look without re-deriving it | | resend cooldown | the earliest a fresh code may be requested | the reissue cycle can be driven as fast as a caller likes | ## What verification does, in order 1. **Load the pending attempt** from the server's own state, using the pre-authentication handle the client presents. No attempt, or an unknown one, is a generic failure. 2. **Check the cap before anything else.** If `failedAttempts` has reached `maxAttempts`, stop here; do not compare, do not reveal that a live code exists. 3. **Spend one attempt.** Increment the counter on every path that is not a success — wrong value, expired value, already-consumed value alike. 4. **Compare digests** and check `expiresAt` and `consumedAt` in the same pass. 5. **On success, set `consumedAt`** and hand the completed attempt on. The record is kept, spent, until its expiry passes so that a replay meets a definite refusal rather than a missing row. ## Why every outcome spends an attempt - If only a wrong value costs an attempt, an expired or consumed value becomes a free probe that says *something was here*. - Uniform cost keeps the three failures indistinguishable to a caller who never held a code, which is the whole point of answering them identically. - It makes the counter mean one simple thing — **submissions against this attempt** — instead of a sub-classification a reader has to reconstruct. - The cost to an honest donor is bounded: the cap is per pending attempt, and a fresh sign-in re-proves the first factor. ## What this record deliberately does not own - **Where the digits come from.** The generator and how many bits sit behind the code belong to the randomness subject, not here. - **How the message reaches the donor.** Queueing, priority, provider failover, retry and the rule that an already-expired code is dropped rather than re-delivered belong to the delivery design. - **The handle itself.** The identifier that carries a half-finished sign-in from the first factor to the second is the pre-login state subject; this record simply hangs off it. - **What happens after success.** Re-issuing the session identifier once the code verifies is the session lifecycle's business. ## What good looks like One row, created at issue, reaped at expiry, holding a digest and four small facts: what it is for, when it dies, whether it has been used, and how many guesses it has absorbed. A reviewer can read that row and answer *how many guesses does an attacker get, against how many accepted values, for how long* without opening any other file. If those three numbers are not all visible in the record, the design is not finished.
- Why hold a digest of the delivered code rather than the code itself?Because for the minutes it is live that value is a working credential. Anyone who can read the row — an operator with query access, a backup, a log line that serialised the record — could otherwise finish the donor's sign-in. A digest lets verification compare without the store ever holding something usable, and it costs nothing, because the server never needs to show the code again after delivery.
- Should the record be deleted the moment a correct code is accepted?No. Mark it consumed and keep it until its expiry passes. A replay of the accepted code then meets a definite refusal on a row that says it is spent, rather than a missing row that has to be answered by guessing what it meant. Reaping on expiry also keeps the failed-guess count around for as long as the attempt it describes could still matter.
- Does this record belong inside the donor's authenticated session?There is no authenticated session yet — that is the point of a pending attempt. It hangs off the pre-authentication handle issued when the first factor succeeded, and it is discarded or promoted when the second factor resolves. The handle's own design, and the re-issuing of the session identifier after success, are separate subjects.
saying these in an interview costs you the question
- Thinks the digits are the control, so only the code's length matters
- Keeps the guess count in the page or in a cookie the caller returns
- Never marks a correct code consumed, so it replays until expiry
- Increments the counter only on a wrong value, not on an expired one
- Stores the delivered code in the clear because it lives only minutes
- Says the code needs no expiry because the donor will use it straight away