skip to content

Per-request rotation of a CSRF token refuses a booking confirmed from a second tab or the back button - why, and what does rotation buy?

level: seniorimportance: should knowfreq 44%

answer

  1. lifetime, not strength
  2. one stored value per session
  3. rendering replaces what older pages hold
  4. stale is not the same as forged
  5. narrower window for a leaked value

basics

~20 s

Rendering a page under per-request rotation overwrites the one value stored against the session, so any page rendered earlier carries a superseded one. Rotation buys a shorter window in which a leaked value is still accepted, and nothing against a document that never held one.

solid answer

~50 s

There is exactly one stored value per session. Under per-request rotation, every render writes a fresh value into the page **and replaces the stored copy**, so a page rendered earlier - a second tab, a page restored from history, a step the user went back to - submits a value the server has already superseded. That is a *stale* value, which is a different failure from one that was never issued. What rotation gains is a narrower window: a value that escaped into an access log or a pasted URL is dead almost immediately. What it does not gain is strength against the attack the scheme exists for, because a document your site never served never held a value to replay. Most systems are better served by one value per session, replaced when the session's authentication state changes.

code

pseudocode · 13 lines
pseudocode
on unsafe request:
    submitted = value from header field or body field
    stored    = session.csrf_token
    if stored is absent:
        reject
    if not constant_time_equal(submitted, stored):
        reject          # stale, mismatched or absent all land here
    proceed

on rendering a page that contains an unsafe form:
    if lifetime is per_request:
        session.csrf_token = random_bytes(16)   # older pages now hold a dead value
    write session.csrf_token into the form

go deeper

for a junior

Know that the value has a lifetime, and that some systems issue a new one on every page. Recognise that a confirmation refused from an old tab is about the value being out of date, not about being logged out.

for a middle

Explain the mechanism: rendering replaces the single stored copy, so every page rendered earlier holds a value the server no longer has. Name the ordinary navigations that produce it - a second tab, the back button, a re-opened step.

for a senior

Separate what rotation buys from what it does not. It narrows the window for a value that escaped; it adds nothing against a cross-site document and no entropy to any individual value. Be ready to describe the ring of recent values and what it costs.

for a principal

Treat it as a usability-versus-exposure trade across the whole estate. A defence that breaks normal navigation gets disabled by whoever answers the complaints, so the durable choice is usually per-session with replacement at authentication-state changes.

## What rotation is choosing between The value has a lifetime, and there are only three honest choices for it: - **one value per session** - generated when the session starts, written into every page that session renders, valid until the session ends; - **one value per response** - a fresh value generated at each render, replacing the stored copy, so only the most recently rendered page holds a usable one; - **one value per session, replaced when the session's authentication state changes** - stable during normal use, renewed at the moment the session's privileges change. Rotation is the second, and every consequence below follows from the four words "replacing the stored copy". ## Why the older page loses Server-side there is one stored value per session. Rendering a page under per-request rotation does two things at once: it writes a new value into the markup, and it overwrites the stored value. Nothing happens to the page rendered a moment earlier - it still holds the value it was given - but the server no longer has a copy of it, so that page's next submission cannot match. This is not a rare corner. It is ordinary browsing: - **a second tab** - the clerk opens the room list in one tab and the booking form in another, and whichever was rendered first now carries a superseded value; - **the back button** - a page restored from history is the markup as it was rendered, hidden inputs and all, so it carries the value from that render; - **a re-opened step** - the clerk goes back to change the room, then forward again to a confirm page that was rendered before the change; - **a form left open** - the booking form sits on screen over lunch while the same session does something else in another window. In each case the submitted value is **stale**: issued by this server, for this session, and since replaced. That is a distinct condition from a value that was never issued and from a request that carried none at all - same rejection, different cause, and only rotation manufactures it. ## What rotation buys, stated honestly Rotation does **not** make the value harder to guess. A per-session value drawn from a strong random source is already unguessable; generating a new one more often does not add entropy to any single one of them. Rotation does **not** help against the cross-site document the whole scheme exists to stop. That document never obtained a value, so there is nothing for it to replay and nothing for rotation to expire. What rotation buys is a **shorter window for a value that has escaped**: into an access log because someone put it in a URL, into a screenshot, into a copied link, onto a shared screen. Under per-session lifetime such a value is good until the session ends. Under rotation it is usually dead by the time anyone could use it. That is a real benefit against a real, if narrower, threat. | lifetime | multi-tab and back-button use | window for a value that leaked | cost | |---|---|---|---| | one per session | works everywhere | the rest of the session | none | | one per response | breaks on every older page | effectively one render | constant user-visible failures | | one per session, replaced on authentication-state change | works everywhere | the rest of the session, cut at each privilege change | trivial | ## If you want both The usual repair is to stop storing exactly one value: 1. Keep a small set of recently issued values for the session rather than a single one, and accept a submission that matches any of them. 2. Retire an entry on use, or after a short age, whichever comes first. 3. Accept the arithmetic honestly - the replay window grows roughly in proportion to how many entries you keep, so a ring of five is five renders of exposure, not one. That restores multi-tab and back-button use while keeping most of the narrowing. It also costs more session state and more code, which is the reason it is not the default. ## The judgment Start from one value per session and replace it when the session's authentication state changes - that covers the moment when the value's meaning genuinely changes. Reach for per-request rotation only when you have a concrete leakage path you cannot close, and when you do, budget for the ring of recent values, because a defence that breaks the back button on a booking flow will be switched off by whoever fields the complaints.

  • How do teams keep rotation and still allow the back button?
    By storing a small set of recently issued values for the session instead of one, and accepting a submission that matches any entry, retiring entries on use or after a short age. Multi-tab and history navigation work again, and the replay window widens roughly in proportion to how many entries are kept - which is the trade being made, not a free win.
  • Does rotation help against a document on another host that never obtained a value?
    No. That document has nothing to replay, so shortening the life of a value it never held changes nothing for it. Rotation addresses a value that escaped the session - through a URL, a log line, a screenshot - not the cross-site forgery the scheme is primarily built for.
  • When is replacing the value genuinely worth doing mid-session?
    When the session's authentication state changes. A session that has just been authenticated is not the same principal it was a moment before, and continuing to accept a value issued to the earlier state blurs that boundary. Replacing it there is cheap, invisible to the user, and does not break navigation the way per-render rotation does.

saying these in an interview costs you the question

  • Says rotation makes the value harder to guess
  • Reads a stale value as an expired session or a login failure
  • Claims per-request rotation is always the stronger choice
  • Assumes a page restored from history is re-rendered with a current value
  • Thinks rotation defends against a document that never held a value