Security Engineering · Web Application Security

Cross-Site Request Forgery and the Problem of Ambient Authority

Why a browser attaches cookies to requests it did not intend to make, which request shapes are reachable without a script, and how token binding restores an intent check.

The same-origin policy stops a script on one origin from reading another origin’s data. It does not stop that script from causing a request to be sent. Sending is not reading, and the browser’s protections are built around the read side. A page on any origin can contain a form whose action points at a bank, and a click on that form’s submit button issues a POST with the bank’s cookies attached, because cookies are attached by the browser based on the destination and not on who caused the navigation.

That asymmetry is what “ambient authority” names. The credential travels with the request because the browser holds it for the destination, and the destination cannot tell from the request alone whether the user meant to send it. Cross-site request forgery is the exploitation of that gap.

The reachable request shapes

Historically, an attacker’s page could do a great deal without any script at all. <form action="https://bank.example/transfer" method="POST"> submits cross-origin: forms are not subject to the same-origin policy for their action URLs. The encoding is restricted to three values — application/x-www-form-urlencoded, multipart/form-data, and text/plain — which is exactly the set of content types a server will accept without a CORS preflight. A cross-origin fetch with a Content-Type outside that set triggers a preflight, and the preflight will fail unless the server grants it, so the “simple request” content types are the ones an attacker can actually use.

GET requests are reachable more cheaply still. An <img src> issues a GET with cookies for the image’s origin; so does a <link rel=stylesheet>, a <script src>, and a top-level navigation. Any state-changing endpoint that responds to GET is therefore forgeable with a single element and no interaction beyond loading a page. This is the reason the rule “GET must be safe” is a security requirement rather than a style preference: it is a statement about which requests the browser will send on an attacker’s behalf without any user action.

The Sec-Fetch-Site header, sent by current browsers, labels each request as same-origin, same-site, cross-site, or none. It is a signal the server can read to distinguish a forged cross-site request from a legitimate one, but it is a defence-in-depth signal, not a substitute for a token: it depends on the browser, and it is absent from non-browser clients that must still be able to use the endpoint.

The natural first idea is to require a custom header, since a cross-origin form cannot set one and a cross-origin fetch that sets one triggers a preflight the server can refuse. That works, and it is the mechanism behind the pattern of sending an X-Requested-With style header from a script. It fails when the endpoint must accept plain form posts, which is the common case for server-rendered applications and for anything that must degrade without JavaScript.

The second idea is to check the Origin or Referer header. Origin is sent on cross-origin requests and on same-origin POSTs in current browsers, and it is not attacker-controllable from a page — a page cannot choose its own origin. Referer is weaker because it can be suppressed by a referrer policy, and a suppressed Referer is indistinguishable from a request that never had one. A check that accepts missing headers is a check that fails open, and many deployments have had exactly that bug.

Both header checks share a limitation: they depend on the request being labelled correctly, and they are evaluated per request rather than bound to the user’s session. A token, by contrast, can be bound to the session and rotated with it, which makes it survive in environments where headers are stripped by proxies.

Synchroniser tokens and what they actually bind

The classic defence is a random token stored server-side in the session record and echoed into every state-changing form as a hidden field. On submission, the server compares the submitted token with the session’s stored token using a constant-time comparison. The token is not a secret from the user; it is a secret from other origins, and the same-origin policy is what keeps it that way. An attacker’s page cannot read the form, so it cannot read the token.

The double-submit variant avoids server-side storage: the token is placed in a cookie and also in the request body, and the server checks that the two match. It is stateless, which is its appeal, and it has a well-known weakness: a subdomain that can set cookies for the parent domain can overwrite the cookie, and then the attacker’s page can supply a matching body value. Host-only cookies and a strict subdomain inventory reduce that exposure, but the variant’s security is contingent on an assumption about who can write cookies for the domain.

Either variant must satisfy several properties that are easy to get individually wrong. The token must be unpredictable, generated by a cryptographic random source rather than a general-purpose PRNG seeded from time. It must be compared in constant time; a byte-by-byte comparison that returns early leaks, through timing, how many leading bytes matched. It must be tied to the session, so that a token minted for one user is not accepted for another. It must be rotated on authentication, or an attacker who obtained a token before login can use it after. And it must be required on every state-changing route, which is an inventory problem rather than a cryptographic one: a route added later that forgets the check is unprotected, and the framework-level enforcement is what makes the control durable.

SameSite as a structural control

The SameSite cookie attribute changes the attachment rule rather than adding a check. SameSite=Strict withholds the cookie from any request whose initiating context is cross-site, including top-level navigations: a user following a link from an email arrives at the site logged out. SameSite=Lax withholds the cookie from cross-site subresource requests and cross-site POSTs, but sends it on top-level GET navigations, which is the compromise that keeps links working.

Lax as a default is worth understanding precisely, because it does not close the forgery class. A cross-site top-level navigation with a GET arrives with cookies, so any GET endpoint with a side effect remains forgeable. Lax also permits cross-site POSTs in a narrow set of circumstances: a form submission to a site that was created recently, within a browser-defined window measured in minutes, is allowed to carry Lax cookies. That carve-out exists to avoid breaking newly opened tabs and is not something an application can rely on either way.

Strict is stronger but shifts the cost onto user experience, and it interacts with the site-versus-origin distinction: a request from https://app.example.com to https://api.example.com is cross-origin but same-site, so SameSite=Strict still attaches the cookie. The attribute is therefore not a substitute for CSRF tokens in a multi-subdomain deployment; it is a second layer that assumes the registrable domain has no attacker-controlled subdomains.

Where the forgery class reappears

Token-based CSRF defence assumes the attacker cannot read the token. Any primitive that lets them read a cross-origin response breaks that assumption, and the primitives are the ones the same-origin policy was supposed to close: a permissive CORS policy that reflects arbitrary origins with credentials enabled, a cross-site scripting hole that lets script run on the target origin, or a JSON endpoint served with a content type the browser will execute as script. Each of those turns the token from unreadable into readable, and the CSRF control collapses without any change to the CSRF code.

JSON hijacking is the historical instance of the third case: array-valued JSON responses were, in older engines, parseable as JavaScript, and a cross-origin <script src> could invoke a callback on the resulting array. The fix was not in the application’s CSRF logic but in the response format — returning an object rather than a bare array, and serving with a non-scriptable content type — and it illustrates that the boundary is drawn by content-type and parsing rules, not by intent.

Login CSRF deserves its own mention because tokens rarely cover it. An attacker who forges a login as their own account leaves the victim operating inside the attacker’s session, and any data the victim then enters is visible to the attacker. The pre-authentication form has no session to bind a token to, so the defence has to be a pre-session token or an Origin check on the login POST. The same reasoning applies to any endpoint reachable before authentication.

What remains after the controls

No single control closes the class. The durable arrangement is a token bound to the session, enforced centrally rather than per route, plus SameSite=Lax or Strict as a structural backstop, plus an Origin check that rejects rather than defaults to allow when the header is missing. Requests that arrive without any of these signals are either same-origin or not from a browser, and the endpoint must decide which of those it is willing to serve.

The controls here and the controls on the read side are complements. The origin comparison decides whether a response may be read; the token decides whether the request should have been sent at all. Neither substitutes for the other, because a forged request that produces no readable response has still changed state.

References

  1. RFC 6265bis: Cookies — HTTP State Management Mechanism — IETF
  2. HTML Living Standard — Forms — WHATWG
  3. Fetch Standard — CORS Protocol — WHATWG
  4. Cross-Site Request Forgery Prevention Cheat Sheet — OWASP Foundation