skip to content

A claim form sits open for an hour, its CSRF token is rejected, and resubmitting fails again - why?

level: middleimportance: should knowfreq 42%

answer

  1. the page is older than the session
  2. rendered value versus current expectation
  3. the rejection issues a new value
  4. the open page never receives it
  5. re-render the form, not just refuse

basics

~20 s

The open page still carries the value it was rendered with, while the server now expects a different one. A fresh value issued in the rejection response never reaches that page, so resubmitting sends the same stale field and earns the same refusal.

solid answer

~50 s

Three failures look identical to the person filing the claim and are not the same thing: the field is absent, the field is present but stale, or the field is present and simply does not match. The hour-old form is the middle case. Whatever the server held when it rendered that page is gone - the session behind it ended, or the value was reissued - and the rendered markup cannot know that. The rejection response usually establishes a current value, but it establishes it in a response the open page never consumed; the page still holds the old string in its hidden field. Pressing submit again replays it. The loop ends only when the rejection path re-renders the form carrying the current value, or the page updates its own field before retrying. On an upload form even a correct re-render costs the claimant their attachments, because a file control cannot be repopulated for them.

code

html · 6 lines
html
<form method="post" action="/claims/4812/documents" enctype="multipart/form-data">
  <input type="hidden" name="csrf_token" value="a3f9c1">
  <input type="file" name="photo" multiple>
  <textarea name="description"></textarea>
  <button type="submit">Upload documents</button>
</form>

go deeper

for a junior

Remember that the value in the page is a snapshot taken when the page was rendered. If the server's expectation has moved on since, that snapshot is stale.

for a middle

Walk the loop in order: render, expiry, refusal, a fresh value landing in a response the open page never sees, then an identical refusal. Name where the cycle breaks.

for a senior

Show the production fix rather than the explanation - lifetimes matched to how long the form really stays open, in-place refresh on long-lived pages, and a rejection that re-renders rather than merely reports.

for a principal

Frame it as an availability trade. A short lifetime narrows the value of a leaked token and multiplies refused submissions on slow forms; the estate needs one deliberate answer rather than a per-form accident.

The stale-token loop is the failure a built-in forgery filter produces most often against honest users, and it is the one least likely to be caught in testing, because tests submit forms within milliseconds of rendering them and real claimants do not. ## Three failures that produce one message A token check can fail in three distinct ways, and conflating them is what makes the loop hard to diagnose: - **Absent.** The submission carried no field at all. Usually a template that forgot it, or a client assembling the request by hand. - **Stale.** A field is present and was genuinely issued by this server, but the state it was issued against no longer exists - the session behind it ended, or the value was reissued since the page was rendered. - **Mismatched.** A field is present and does not correspond to anything the server holds for this session. This is what an actual forgery looks like, and also what a copied-and-pasted request looks like. The hour-old claim form is squarely the middle case, and the middle case is the only one of the three with a benign explanation and a remedy the server can offer. ## Why the fresh value does not reach the page A sensible rejection path establishes a current value, so that the next attempt has something to carry. The trap is where that value lands. It lands in the **rejection response** - as a field of a re-rendered page, or as state associated with the session, or as a value the client is expected to read. The page the claimant is looking at was rendered an hour earlier and is not listening. Its hidden field still contains the string it was born with. So the sequence runs: 1. The page renders, carrying value **A**. 2. Time passes. The server's expectation moves to **B**. 3. The claimant submits. **A** is rejected, and the response carries or establishes **B**. 4. The claimant presses submit again on the same page, which still carries **A**. 5. Rejected again, identically, for as long as they keep trying. What breaks the loop is a rejection path that actually changes what the page holds: re-render the form with the current value as part of the rejection response, or have the page fetch the current value and update its own field before retrying. A rejection that merely reports the problem and leaves the page untouched is a rejection the user can only obey by reloading - and reloading is precisely what nobody does when a long form is filled in. ## What the re-render costs on an upload form Here the remedy is itself expensive. A file control's selection cannot be set programmatically - that restriction exists so a page cannot help itself to a file the user never chose - so any path that replaces the page loses the attachments. The claimant re-renders into a form that has their text fields back and their photographs gone. That pushes the real fix upstream of the rejection: - **Keep the value alive as long as the form plausibly stays open.** If a claim form realistically sits open for half an hour, a value that expires in five minutes is a defect in the form's design, not a security posture. - **Warn before the state expires** rather than after, so the person can act while the page is still valid. - **Refresh the field in place** when the page is long-lived, which changes the failure from a refused submission into a background update. - **Separate the three failure causes in the response** so the page can react correctly. A stale value should re-render the form; a mismatch should not silently do the same thing, because that is the case where you may genuinely be looking at a forged submission. ## Why 'the user took too long' is the wrong diagnosis It is tempting to file this under user error. It is not: nothing the claimant did was wrong, and the request that was refused was one the server had itself invited an hour earlier. The defect is a contract between two clocks - how long the server keeps a value valid, and how long a page carrying it stays on screen - that nobody wrote down. Interviewers ask about the loop because recognising that the fix lives in the rejection path and the lifetime, rather than in the check, is the difference between shipping this and shipping around it.

  • How should the rejection response distinguish a stale value from a mismatched one?
    With separate machine-readable reason codes, decided server-side: stale means the value was issued here but the state behind it is gone, mismatched means it corresponds to nothing this session holds. The first justifies re-rendering the form automatically; the second is the shape a genuine forgery takes, so it should be reported and recorded rather than smoothed over with a silent retry.
  • Why is lengthening the value's lifetime not a complete answer?
    It moves the boundary rather than removing it, and it keeps a valid value usable for longer, which widens the window in which a leaked one is worth something. Pair a realistic lifetime with a page that can refresh its own field, so a long-lived form stops depending on the lifetime being generous.
  • A test submits the form immediately after rendering it and never sees this. What would reproduce it?
    Render the form, then expire or reissue the server-side state before submitting - the same thing an hour of wall clock does, without the hour. Assert both that the first submission is refused and that the page the refusal produces can be submitted successfully without a manual reload.

saying these in an interview costs you the question

  • Blames the claimant for taking too long over the form
  • Treats a stale value as evidence of an attack in progress
  • Thinks issuing a fresh value in the rejection fixes the open page
  • Assumes the browser re-renders the form by itself after a refusal
  • Claims a page reload restores the files the claimant had attached