Skip to content
All posts
webrevised July 10, 20267 min read

Sessions, cookies, CORS and CSRF: the browser's trust rules

Which origin can read what, why a cookie is scoped differently from everything else in the browser, and the four ways a session is stolen without ever finding an XSS: CSRF, a permissive CORS policy, cookie tossing, and session fixation.

A large share of web challenges come down to one question: can this origin read that response? The browser's answer is the same-origin policy, and every bug in this post is a place where the rules have an exception, a legacy carve-out, or a header that opts out of them.

Worth being precise up front, because the imprecision is where the bugs are. The same-origin policy governs *reading*. It does not govern *sending*. A page on any origin may send a request to any other origin with the victim's cookies attached; it simply may not read the reply. CSRF exploits the sending; CORS misconfiguration restores the reading.

Cookies are scoped differently from everything else

An origin is scheme, host and port together. A cookie's scope is none of those three the same way:

PropertyOriginCookie
SchemePart of the originIgnored unless Secure is set
PortPart of the originIgnored entirely
HostExact matchDomain=example.com covers every subdomain
PathNot part of itPath=/admin restricts sending, but not reading by script

Three consequences that challenges are built on. Any subdomain can set a cookie for the parent domain, so an XSS or a takeover on blog.target reaches app.target's session. Any subdomain can overwrite a parent-domain cookie, which is cookie tossing. And `http://` and `https://` share cookies unless Secure is set, so a plaintext request leaks a session that was only ever issued over TLS.

The flags, and what each actually prevents

  • `HttpOnly` hides the cookie from document.cookie. It does not stop an XSS from *using* the session - the script can still make authenticated requests. It stops exfiltration of the value, not abuse of it.
  • `Secure` stops the cookie being sent over plain HTTP. Without it, one http:// subresource leaks the session.
  • `SameSite=Strict` stops the cookie on every cross-site request, including top-level navigation. Effective, and it breaks incoming links, which is why it is rare.
  • `SameSite=Lax` (the modern default) sends the cookie on top-level GET navigations only. So a CSRF that works with a GET link still works, and one requiring a POST does not.
  • `SameSite=None` requires Secure and opts back in to everything. A challenge that sets it is telling you CSRF is intended.
  • `__Host-` prefix enforces Secure, no Domain, and Path=/ - which specifically blocks cookie tossing from a subdomain. Its absence on a session cookie in a multi-subdomain challenge is a hint.

CSRF: making the victim send the request

The attack is a page on your origin that causes the victim's browser to issue a state-changing request to the target, with their cookies attached. You never see the response and you do not need to.

<form action="https://target/account/email" method="POST">
  <input name="email" value="attacker@you.example">
</form>
<script>document.forms[0].submit()</script>

For it to work, three things must hold: the action is state-changing, the session rides on a cookie rather than a header, and there is no unpredictable token. The defences and their failures:

DefenceHow it fails
A CSRF tokenNot validated on some methods; validated only if present; tied to no session, so any valid token works; predictable or reflected in a GET response.
SameSite=LaxBypassed by a GET-based state change, or by a route that accepts _method=POST override.
Referer checkingMissing Referer allowed; checked with startsWith, so https://target.you.example passes; suppressed entirely with Referrer-Policy: no-referrer on your page.
Requiring JSONContent-Type: text/plain sends cross-origin without preflight, and a lenient parser still reads the body as JSON.
A custom headerGenuinely effective - it forces a preflight. Unless CORS is misconfigured, below.

CORS: opting out of the same-origin policy

CORS lets a server say "this other origin may read my responses". It is a deliberate relaxation, and the misconfigurations are all about *which* origins get named.

Access-Control-Allow-Origin: https://app.target
Access-Control-Allow-Credentials: true
  • Origin reflection. The server echoes whatever Origin you send. With Allow-Credentials: true this is total: your page reads authenticated responses from the target. Test by sending Origin: https://you.example and looking at what comes back.
  • `null` allowed. Access-Control-Allow-Origin: null is exploitable because a sandboxed iframe, a data: URL, or a redirect produces a null origin, and you control those.
  • Sloppy suffix matching. A check for endsWith("target.com") accepts eviltarget.com. A check for startsWith("https://target.com") accepts https://target.com.you.example.
  • Subdomains trusted wholesale. Any XSS or dangling DNS record on any subdomain becomes a read primitive against the main application.
  • **Allow-Origin: * with credentials.** The browser refuses this combination, so it is not directly exploitable - but it does mean unauthenticated responses are readable, which still leaks internal API data.

A working CORS misconfiguration is strictly better than XSS for reading data, because it needs no injection point at all - just a victim who visits your page while logged in.

Tossing

From any subdomain, document.cookie = "session=X; Domain=target.com; Path=/" writes a cookie the parent domain will send. The browser does not tell the server which host set a cookie, so the application cannot distinguish yours from its own. When two cookies of the same name exist with different paths, the more specific path wins - which is how you shadow a session cookie without deleting it.

This is the reason subdomain takeover matters in challenges that seem to have nothing to do with DNS, and the reason the __Host- prefix exists.

Fixation

If the application accepts a session id you supply and does not rotate it at login, you can set a known id in the victim's browser, wait for them to authenticate, and then use that id yourself. The test is simple: note your session id, log in, and see whether it changed. If it did not, fixation is available - and combined with tossing, you do not even need an injection point to set it.

Where the session actually lives

Before attacking the transport, look at the token itself. Several challenges in this space are really token challenges:

  • A JWT in a cookie brings its own attack surface - alg=none, a weak HMAC secret, algorithm confusion.
  • A Flask session cookie is base64 with a signature; the payload is readable without the key and forgeable with a cracked one.
  • A cookie that is base64 of a serialised object is an insecure deserialization target, not a session problem.
  • An encrypted cookie with no MAC is malleable - see block cipher modes for bit flipping and padding oracles.
  • A sequential or timestamp-derived id is predictable, and PRNG prediction covers recovering the generator.

A session-attack checklist

  1. Decode the session token. Half the time the challenge is inside it and none of this post applies.
  2. Read the cookie flags. Missing Secure, missing SameSite, a Domain covering subdomains, and no __Host- prefix are each a route.
  3. Send an Origin header on an authenticated endpoint and see what comes back. Reflection with credentials ends the challenge.
  4. Try Origin: null separately - it is a different code path.
  5. Test CSRF by removing the token, not by changing it. Then by changing the method, then the Content-Type.
  6. Check whether the session id rotates at login.
  7. If there are subdomains in scope, consider what one of them could set for the parent.

The unifying idea is that the browser enforces a boundary the server cannot see. The server receives a request with a valid cookie and has no way to know whether the user meant to send it, which origin's page caused it, or which host set the cookie in the first place. Every attack here is a way of exploiting that blindness, and every defence is the server asking for a piece of evidence - a token, an origin header, a flag - that only a legitimate request would carry.