skip to content

How do you keep a double-submit CSRF value stateless while making a value planted by a sibling host fail the check?

level: seniorimportance: should knowfreq 42%

answer

  1. equality is not ownership
  2. bind it to the session
  3. keyed code, recomputed not stored
  4. a sibling cannot mint for this session
  5. prefix constrains setting, not script

basics

~20 s

Bind the value to the session instead of only to itself: carry a random part plus a keyed message authentication code over the session identifier and that random part. The server recomputes it from the session it already has, so nothing is stored.

solid answer

~50 s

The plain pattern fails because the pair proves equality, not ownership. Issue the value as two parts — a fresh random string and a message authentication code computed with a server-held secret over the session identifier joined to that random string. On each unsafe request the server checks both copies are present and equal, splits the random part out, recomputes the code from the session identifier it already has on the request, and compares. Nothing is stored, so the pool stays stateless. A sibling host can still write a cookie, but it cannot produce a code for *this* session: it does not hold the secret, and a value harvested from the attacker's own session is bound to a different session identifier. Naming the cookie with the `__Host-` prefix hardens the write path further, since a cookie carrying that prefix is not accepted with a `Domain` attribute.

code

pseudocode · 15 lines
pseudocode
function issue(session_id, secret):
    random = fresh_random_string(32)
    code = hmac_sha256(secret, session_id + "|" + random)
    return random + "." + base64url(code)

function check(request, session_id, secret):
    from_cookie  = request.cookie("__Host-csrf")
    from_request = request.submitted_value()
    if from_cookie is absent: reject 403
    if from_request is absent: reject 403
    if from_cookie != from_request: reject 403
    random, code = split_once(from_cookie, ".")
    expected = base64url(hmac_sha256(secret, session_id + "|" + random))
    if code != expected: reject 403
    accept

go deeper

for a junior

Take away the shape: the value is not just random, it carries a proof that your server made it for this particular logged-in session.

for a middle

Explain why recomputing a keyed code keeps the check stateless, and what each of the two parts of the value is for.

for a senior

Cover the operational edges: secret rotation with an overlap window, reissue at login, what to do before a session exists, and the limits of the prefix hardening.

for a principal

Weigh a distributed secret and its rotation against shared session storage, and state which assumption each option leaves you holding.

## Bind the value to the session, not to itself Every weakness of the plain pattern reduces to one sentence: **the check verifies that two strings are equal, and equality is a property the attacker can arrange.** The repair is to make the value carry a proof that it was minted by your server *for this session*, so that equality stops being sufficient and provenance becomes checkable without a lookup. The standard construction is two-part: - a fresh **random part**, which keeps values distinct and unguessable; - a **keyed message authentication code** over the session identifier joined to that random part, computed with a secret only your servers hold. The cookie carries both parts, and the page echoes both parts back. The server never stores anything: it already receives the session identifier on the request, so it can recompute the code and see whether it matches. That is the trick — the binding is *recomputable*, not *remembered*. ## What the server does on each request 1. Read both copies — the one in the `Cookie` request header, and the submitted one. **Absent either, reject.** 2. Compare them. Unequal, reject. 3. Split the value into its random part and its code. 4. Recompute the code from the server secret, the session identifier carried on this request, and that random part. 5. Mismatch, reject; match, accept the write. Step 4 is the whole difference. Steps 1 and 2 are what the plain pattern already did, and on their own they accept a planted pair happily. ## Why a planted pair now fails A sibling host under the shared registrable domain still has the ability to write a cookie your host will read — that capability is a property of the cookie layer and no application-level change removes it. What changes is that writing a cookie is no longer enough: | What the attacker can do | Result against the plain pair | Result against the session-bound pair | |---|---|---| | Invent a value and plant it in a cookie | Accepted — both halves match | Rejected — no valid code over the victim's session identifier | | Harvest a valid value from his own logged-in session and plant that | Accepted — both halves match | Rejected — the code is bound to *his* session identifier, not the victim's | | Replay a value the victim used earlier in the same session | Accepted | Accepted — this construction binds to the session, not to a single request | That last row is the honest limit. Session binding closes cookie injection; it does not by itself make a value single-use, and a value the victim's own page exposed to an attacker in the same session still verifies. ## What the cookie-name hardening adds, and what it does not Naming the cookie with the `__Host-` prefix constrains how the cookie may be **set**: a cookie whose name carries that prefix is not accepted by a conforming browser when it comes with a `Domain` attribute, which is exactly the write a sibling host needs for its cookie to reach your host. It is real hardening and it is nearly free. Be honest about its shape, though: - It constrains **where the cookie may be set**, not **who may run script in the user's browser**. Script executing in a page on *your own* host is unaffected by the prefix. - It is enforced by the browser, so it protects users on conforming clients and says nothing about anything else. - It is a second lock, not the lock. A deployment that relies on the prefix alone, with an unsigned self-referential value underneath, has one mechanism between it and a forged write. The layered version is: bind the value cryptographically **and** name the cookie so the parent-scoped write is refused. The first makes a planted value useless; the second makes planting it harder in the first place. ## Costs and edges worth stating - **The secret becomes an asset.** It must be generated properly, distributed to every instance, and rotated. Rotation needs an overlap window where the previous secret still verifies, or every in-flight form breaks at the moment of the rotation. - **Login must reissue.** The code is bound to a session identifier, so when that identifier changes the old value stops verifying. Issue a fresh pair as part of establishing the new session, or the user's first write after signing in is rejected. - **Anonymous requests have no session to bind to.** A pre-login form has no identifier, so decide deliberately what the binding is there rather than silently falling back to the plain, self-referential form. - **Cookies are not isolated by port or by scheme.** The binding does not care — it is checked by your server — but do not describe the port or the scheme as the thing that saved you.

  • Does naming the cookie with the `__Host-` prefix remove the need to bind the value to the session?
    No. The prefix closes the parent-scoped write a sibling host needs, which raises the cost of planting a cookie; it does nothing about script running on your own host, and it is enforced by the client rather than by you. Treat it as a second lock over a bound value, not as a replacement for binding.
  • What has to happen when the server secret is rotated?
    Verification must accept the previous secret for an overlap window at least as long as a page may sit open, or every form already rendered fails at the instant of rotation. Issue new values under the new secret immediately and retire the old one once the window closes.
  • Why is the value reissued at login?
    Because the code is computed over the session identifier, and establishing a new session changes that identifier. A value minted before sign-in no longer recomputes to the same code, so the first write afterwards would be rejected unless a fresh pair is issued with the new session.
  • Does this construction stop an attacker who already runs script on the victim's own host?
    No, and claiming otherwise is the common overstatement. Script in a page on your own host can read the cookie and compose requests exactly as your page does, so the pair verifies. Session binding closes the sibling-host write path, not script execution inside your own origin.

saying these in an interview costs you the question

  • Says the prefix alone makes the pattern safe from sibling hosts.
  • Binds the code to the user identifier instead of the session identifier.
  • Rotates the secret with no overlap window and breaks open forms.
  • Thinks session binding also makes the value single-use.
  • Claims a planted value works if the attacker copies it from his own session.
  • Assumes binding protects against script running on your own host.