Chapter 15 · Part III · Software & the web

Sessions, cookies, CSRF & CORS

HTTP forgets you the moment each request ends. The web remembers you anyway, with a small token your browser hands back on every visit. That token is worth as much as your password, and your browser will happily attach it to requests that other websites start. This chapter is about the rules that keep one site from spending another site's trust: origins, cookie attributes, CSRF defenses, and CORS, the one rule people most often get backwards.

3parts make an origin: scheme, host and port. Two URLs are same-origin only if all three match (RFC 6454)
3SameSite values a cookie can carry: Strict, Lax and None. Chromium browsers treat a cookie with none set as Lax
400 daysthe longest lifetime the cookie standard's revision recommends a browser honor, however far away the Expires date

Common mix-up: CORS does not protect your server. It is a set of headers your server uses to tell browsers which other origins may read its responses. The request still arrives, and a server that changes something on arrival has already changed it. Stopping forged requests is the job of CSRF defenses, not CORS.

Read first: this chapter assumes you know what a request to a host and port looks like on the wire. If ports and connections are new, start with TCP, UDP & ports on networks.jelia.nyc. Script injection (XSS) is Chapter 13; signing in, MFA and passkeys are Chapter 4; HTTPS and HSTS are Chapter 3.

The browser's first rule

Origins, sites and the same-origin policy

Your browser runs code from every site you visit, often several at once: a news page, the ads inside it, a video player from somewhere else, your email in another tab. All of that code shares one program and one network connection to the world. Without a rule, a script from any page could read your mail, your bank balance or your company wiki, just by asking for those pages with your cookies attached.

The rule is the same-origin policy. Every document belongs to an origin, which is three values taken together: the scheme (https), the host (app.site-a.example) and the port (443, the default for https). Script running in one origin can freely read what belongs to that same origin: its pages, its storage, the responses to its own requests. It cannot read what belongs to a different origin. Change any one of the three values and you are in a different origin: http://app.site-a.example is not https://app.site-a.example, and https://app.site-a.example:8443 is not either.

There is a second, looser idea called a site. A site is the scheme plus the registrable domain: the part of the host name someone can actually buy, one label to the left of a public suffix such as .com or .co.uk. People write it as eTLD+1, "effective top-level domain plus one". app.site-a.example and shop.site-a.example are different origins but the same site, because both sit under site-a.example. The browser learns which suffixes are public from the Public Suffix List, a shared file maintained for exactly this purpose.

https://app.site-a.example:443/account scheme host port = ORIGIN all three must match scheme registrable domain (eTLD+1) = SITE ignores subdomain, port shop.site-a.example → different origin, same site site-b.example → different origin, different site
Origin is the strict boundary for reading data. Site is the looser boundary cookies use for SameSite. Modern browsers include the scheme in "site", so http and https versions of a domain count as different sites.

The same-origin policy is narrower than most people expect, and the gap is where this whole chapter lives. It stops script from reading cross-origin data. It does not stop pages from sending requests to other origins or embedding their content. That was a deliberate choice in the 1990s, because the web was built on links, forms and images that point anywhere, and it cannot be taken back without breaking the web.

A page on site-b.example tries to…Allowed?Why it matters
link to site-ayesTop-level navigation. Lax cookies for site-a travel with it.
submit a form to site-ayes, sendsThe request is delivered. The page cannot read the reply. This is the root of CSRF.
embed site-a's image, script, iframeyes, embedsContent shows, but the page cannot read pixels, source or the frame's document. Framing is what clickjacking abuses.
fetch() site-a and read the bodyno, unless CORSThe request may well be sent; the browser withholds the response unless site-a opts in with CORS headers.
read site-a's cookies or storagenodocument.cookie and localStorage are partitioned by origin (cookies by domain).
reach into a site-a iframe's DOMnoCross-origin frames talk only through postMessage, which should check the sender's origin.
Go deeper: how "site" is computed, and why it includes the scheme

RFC 6454 defines an origin as the (scheme, host, port) triple and says two origins are the same only if all three are identical. The HTML standard defines a site as the scheme plus the registrable domain. Early versions of SameSite were "schemeless": http://site-a.example and https://site-a.example were the same site. The cookie standard's revision (RFC 6265bis) makes the comparison "schemeful", so a page served over plain http cannot ride on an https site's SameSite cookies. That closes a hole where an attacker on the network could inject an http page that looks same-site.

The registrable domain depends on the Public Suffix List. github.io is on that list as a private suffix, for example, so alice.github.io and bob.github.io are different sites even though they share a parent domain. If your company gives customers subdomains of a shared domain, you should either list it as a public suffix or give them a different registrable domain from the one your own login lives on.

A note the browser carries for you

Cookies and their attributes

A cookie is a name and a value that a server asks the browser to store, using a Set-Cookie response header. Afterwards the browser attaches every matching cookie to later requests in a single Cookie header. "Matching" is decided entirely by the attributes the server set, and the server never sees those attributes again; it only sees name=value. That is why the attributes matter so much: they are the only instructions the browser has.

Set-Cookie: __Host-sid=Yw3k…(128+ random bits)…; Path=/; Secure; HttpOnly; SameSite=Lax
AttributeWhat the browser doesWhat it protects
SecureSends the cookie only over https, and refuses to let an http page set or overwrite it.Session IDs being read off an untrusted network.
HttpOnlyHides the cookie from document.cookie and other script access. It still rides along on requests.Theft by injected script. It does not stop injected script from using the session.
SameSite=StrictSends the cookie only on same-site requests, never when the request starts on another site, even a plain link.CSRF, strongly. Cost: you arrive from a link looking signed out.
SameSite=LaxLike Strict, except it also goes along on top-level navigations with a safe method (GET), such as clicking a link.CSRF on POST, PUT and DELETE, and on embedded or scripted cross-site requests.
SameSite=NoneSends on every request, cross-site included. Must be combined with Secure or the browser discards the cookie.Nothing; this is the opt-out for embeds and federated logins.
Domain=Widens the cookie to the named domain and every subdomain. Leave it out and the cookie is host-only.Nothing: Domain only ever widens scope. Omit it unless you need it.
Path=Sends only for URLs under the path.Tidiness, not security: any page on the origin can still read other paths' cookies.
Max-Age / ExpiresKeeps the cookie across browser restarts until then. Without either it is a session cookie, gone when the browser decides the session is over.Limits how long a stolen cookie is useful, together with server-side expiry.
__Host- prefixAccepts the cookie only if it is Secure, has Path=/ and no Domain: locked to exactly one host.Sibling subdomains and http pages planting or overwriting your session cookie.
__Secure- prefixAccepts the cookie only if it is Secure.The weaker cousin of __Host-.

Two details trip up nearly everyone. First, cookies do not follow the origin rule. They were invented before it, and they are scoped by domain and path but not by port, and only by scheme if you set Secure. Two services on different ports of one host share cookies. Second, the Domain attribute feels like a restriction and is the opposite: Domain=site-a.example sends the cookie to every subdomain, including the forgotten marketing microsite. A host-only cookie is narrower. The __Host- prefix enforces the narrow form, and a session cookie should use it.

Third-party cookies are cookies sent on requests to a different site from the one in the address bar, such as an embedded widget or an ad frame. Browsers have been restricting them for privacy reasons, unevenly. Safari blocks them by default, Firefox keeps a separate cookie jar for each top-level site, and Chrome's plans have changed more than once. The details move year to year, so check MDN's current page rather than any book, this one included. For security the useful lesson is simple: do not build anything that depends on cross-site cookies, and do not rely on their absence as your CSRF defense either.

Instrument: the cookie jar and request builder

A simulated browser holding cookies that https://app.site-a.example set. Change any attribute, then fire a request from a different place. The jar applies the RFC 6265bis rules (storage checks, domain and path match, Secure, SameSite, HttpOnly) and explains every decision. Nothing leaves this page.

CookiePrefixDomainPathSecureHttpOnlySameSite
What the server receives
Decision for each cookie
    Go deeper: the exact order of checks in RFC 6265bis

    When a Set-Cookie arrives, the browser parses the attributes, then runs a series of storage checks and silently ignores the cookie if any fails: the Domain attribute must domain-match the host that set it (a server cannot set cookies for an unrelated domain, nor for a public suffix); a Secure cookie must come from a secure origin; SameSite=None requires Secure; and the prefixes impose their own rules. A cookie with the same name, domain and path replaces the old one.

    When building a request, the browser walks its store and keeps a cookie only if: the request host domain-matches (exactly, for host-only cookies); the request path path-matches; the scheme is secure if the cookie is Secure; the cookie has not expired; and the SameSite rule allows this request. For SameSite the browser computes whether the request is same-site by comparing the target's site with the "site for cookies" of the context that started it: for a top-level navigation that means the page the user was on, and for a subresource it means the top-level page and every frame in between. Lax lets cross-site requests through only when they are top-level navigations using a safe method.

    Lax-by-default has one wrinkle. The draft allows a browser to use "Lax-allowing-unsafe" for cookies with no SameSite attribute, and Chrome did so: for about two minutes after such a cookie is set, it also rides along on a cross-site top-level POST, to avoid breaking some sign-in flows. Do not lean on the default; set SameSite explicitly.

    A row in a table

    Session management that holds up

    After you sign in, the server creates a session: a record that says "this visitor is J, signed in at 09:14, last seen at 09:31". It sends back a session ID, a long random string that is just the key to that record, in a cookie. From then on, whoever presents that ID is you as far as the server is concerned. The OWASP Session Management Cheat Sheet boils the job down to a short list, and every item on it closes a specific hole.

    • Make the ID unguessable. Generate it from a cryptographically secure random number generator, with at least 64 bits of entropy (OWASP's floor; 128 is the common choice). Never derive it from the user name, the time or a counter.
    • Issue a new ID at sign-in. If the ID stays the same across login, an attacker who got a victim to use an ID the attacker already knew (a session fixation attack, CWE-384) is signed in as the victim the moment the victim signs in. Rotate the ID whenever privilege changes: login, step-up to MFA, switching roles.
    • Expire it twice. An idle timeout ends a session nobody has touched for a while; an absolute timeout ends it after a fixed span however busy it is. OWASP's examples are 2 to 5 minutes idle for high-value applications, 15 to 30 for low-risk ones, and an absolute limit that matches a working day.
    • Invalidate on the server. Logout must delete or revoke the server-side record. Clearing the cookie in the browser alone leaves any copy of the ID fully working.
    • Keep it out of URLs. IDs in URLs leak into logs, browser history and the Referer header. Cookies with the attributes above are the standard carrier.
    // risky: same ID before and after login;
    // logout only clears the browser
    on login(user):
        session.user = user
    on logout:
        response.clearCookie("sid")
    // sound: fresh ID at login,
    // record destroyed at logout
    on login(user):
        session.regenerateId()  // old ID dead
        session.user = user
    on logout:
        store.delete(session.id)
        response.clearCookie("__Host-sid")
    T ≈ 2b ÷ (r × S)
    The time T for a blind guesser to have better-than-even odds of hitting some live session, when IDs have b random bits, the guesser tries r IDs per second, and S sessions are live at once. Every extra bit doubles T.
    Time to likely hit
    Possible IDs
    Verdict

    With OWASP's worked example (64 bits, 100,000 live sessions, 10,000 guesses a second) this gives about 585 years, the figure in the cheat sheet. Drop to 32 bits and it falls to seconds.

    Go deeper: why the formula looks like that

    Each guess hits one of S live IDs out of N = 2b possible IDs with probability S/N. After n guesses the chance of no hit is about e−nS/N. Setting n = N/S, the number of guesses made in time T = N/(rS), gives a success chance of 1 − e−1, about 63 percent. Rate limiting and lockouts reduce r; short timeouts reduce S; neither substitutes for b, which you control completely and which is cheap.

    High entropy defends against guessing only. It does nothing once the ID is copied: by script, by malware on the device, from a log that recorded it. That is why the cheat sheet also suggests storing a hash of the ID server-side when read access to the session store is a concern, and why short lifetimes and server-side revocation matter even with perfect randomness.

    Spending someone else's trust

    Cross-site request forgery

    Put the last two sections together and the problem appears by itself. The same-origin policy lets any page send a request to your site. The browser attaches your site's cookies to requests it sends to your site. So a page on another site can cause your browser to send a fully authenticated request to your site, and your server sees a valid session cookie and has no reason to doubt it. That is cross-site request forgery (CSRF, CWE-352). The attacker never sees the cookie or the response; they do not need to. They only need the side effect: a changed email address, a transfer, a deleted record.

    CSRF only works against requests that change something, and only when the server decides who you are purely from something the browser attaches automatically: cookies, HTTP basic auth, or a client certificate. An API that requires a token in an Authorization header, which a foreign page cannot add, is not exposed in the same way.

    The defenses, roughly in the order OWASP recommends layering them:

    1. Never change state on GET. Links, image tags and redirects all make GET requests, and Lax cookies ride on cross-site top-level GETs. A GET /delete?id=7 endpoint is defenseless.
    2. SameSite cookies. Lax stops the cookie on cross-site POSTs and on scripted or embedded cross-site requests; Strict stops it on everything cross-site. It is defense in depth rather than the whole answer, because it trusts every subdomain of your site, and not every browser enforces Lax by default.
    3. A CSRF token. The server puts an unpredictable value in its own forms and pages, tied to the session, and rejects any state-changing request that does not echo it back. The foreign page cannot read your pages, so it cannot learn the token. Most web frameworks have this built in; turn it on rather than writing your own. Where the server keeps no state, the signed double-submit cookie variant pairs a cookie with a matching, HMAC-signed request value.
    4. Check where the request came from. Browsers attach an Origin header to POST and to CORS requests. Rejecting unsafe requests whose Origin is not your own is simple and strong. Referer is a weaker fallback because it is sometimes stripped.
    5. Fetch Metadata. Modern browsers tag every request with Sec-Fetch-Site (same-origin, same-site, cross-site or none), Sec-Fetch-Mode and Sec-Fetch-Dest. Page script cannot forge these. A short server policy ("allow same-origin, same-site and none; allow cross-site only for top-level GET navigations") blocks CSRF along with several other cross-site attacks.
    // a resource-isolation policy using Fetch Metadata (illustrative)
    site = request.header("Sec-Fetch-Site")
    // older browsers send no header: rely on the other layers
    if site is missing: allow
    if site in ["same-origin", "same-site", "none"]: allow
    // ordinary links from other sites still work
    if mode == "navigate" and method == "GET": allow
    otherwise: reject 403

    Instrument: CSRF defense matrix

    Rows are forged-request scenarios against https://app.site-a.example, which uses a session cookie. Switch defenses on and off in the column headers; each cell says whether that defense stops that scenario, and the last column says whether the forged request still succeeds. Click a row for the reasoning.

    Scenarios stopped
    Weakest remaining
    Pick a rowClick any scenario to see why each defense does or does not apply.
    Go deeper: why XSS beats every CSRF defense

    Every defense above relies on one thing: the attacker's code is running somewhere other than your origin. A token works because a foreign page cannot read it; SameSite works because the request is cross-site; Origin and Fetch Metadata work because the browser stamps the true source. If an attacker manages to run script inside your origin, through cross-site scripting, all four see a legitimate same-origin request. The script can read the token out of the page and send it. That is why the CSRF chapter of any defense plan has a dependency on the injection chapter: see Chapter 13 for output encoding and Content Security Policy.

    Two more subtleties. "Same-site" is a looser boundary than you may want: if any subdomain of your registrable domain can be taken over (a dangling DNS record pointing at a deleted cloud bucket is the classic), SameSite treats it as family. And login forms need CSRF protection too: a forged login can sign the victim into the attacker's account, so that whatever the victim then types is saved where the attacker can read it.

    A guest list for readers

    CORS: a controlled hole in the same-origin policy

    Sometimes you want another origin to read your responses: your single-page app on app.site-a.example calling your API on api.site-a.example, or a partner's dashboard calling your public data. Cross-Origin Resource Sharing (CORS, defined in the WHATWG Fetch standard) is how a server tells browsers "these origins may read my responses". It only ever relaxes the same-origin policy. A server that sends no CORS headers is as protected as it was without CORS.

    It works like this. When script calls fetch() on another origin, the browser adds an Origin header naming the page's origin. The server answers with Access-Control-Allow-Origin (ACAO): either that exact origin, or * for "anyone". The browser compares, and hands the response to the script only if it matches. If the script asked for credentials (credentials: "include", so cookies go along), the rules tighten: the server must name the exact origin, * is refused, and it must also send Access-Control-Allow-Credentials: true.

    Requests that an ordinary HTML form could already have sent, called simple requests (GET, HEAD or POST, with only a few safelisted headers and a form-style or plain-text content type), are sent straight away. Anything else, such as PUT or DELETE, a JSON body, or an Authorization header, first triggers a preflight: an OPTIONS request, without cookies, that asks "would you accept this method and these headers from this origin?" Only if the server's answer passes does the browser send the real request. Preflights exist to protect servers built before CORS, which assumed no browser would ever send them a cross-origin PUT.

    // the classic mistake: echo whatever Origin arrives, with credentials
    response.header("Access-Control-Allow-Origin", request.header("Origin"))
    response.header("Access-Control-Allow-Credentials", "true")
    // exact allowlist; say Vary: Origin so caches keep answers apart
    ALLOWED = {"https://app.site-a.example", "https://site-b.example"}
    origin = request.header("Origin")
    if origin in ALLOWED:
        response.header("Access-Control-Allow-Origin", origin)
        response.header("Access-Control-Allow-Credentials", "true")
    response.header("Vary", "Origin")

    The mistake above (CWE-942) is common because it makes an error message go away. Reflecting any Origin with credentials tells every website on the internet that it may read your users' signed-in responses, which undoes the same-origin policy for your API completely. Its relatives are just as bad: checking that the origin "ends with" or "contains" your domain (so notsite-a.example passes), and allowing the origin null, which sandboxed iframes and local files send and which anyone can therefore produce.

    Instrument: the CORS decision engine

    Compose a cross-origin fetch() from a page to https://api.site-a.example/v1/profile, and the API's CORS configuration. The engine follows the Fetch standard: is the request simple or preflighted, does the preflight pass, is the real request sent, and may the script read the answer. It also audits the configuration. Simulation only.

    The request (browser side)
    The API's CORS configuration (server side)
    On the wire
    Steps and configuration audit
      Go deeper: the exact CORS checks in the Fetch standard

      The CORS check on a response: if Access-Control-Allow-Origin is missing, fail. If the request's credentials mode is not "include" and the value is *, pass. If the value is not byte-for-byte the request's origin, fail. If credentials mode is not "include", pass. Otherwise Access-Control-Allow-Credentials must be exactly true.

      A request is preflighted when its method is not GET, HEAD or POST, or it carries any header that is not CORS-safelisted. The safelist is short: Accept, Accept-Language, Content-Language, and Content-Type only with the three form-compatible values, each under length and character limits. The preflight carries Access-Control-Request-Method and Access-Control-Request-Headers and is sent without credentials. The response must pass the CORS check, then list the method (unless it is safelisted) in Access-Control-Allow-Methods and every unsafe header in Access-Control-Allow-Headers. A * in those lists works only for non-credentialed requests, and never covers Authorization. Access-Control-Max-Age lets the browser cache a passing preflight.

      A failed CORS check shows up in page script as a bare network error (TypeError: Failed to fetch); the details appear only in the developer console. That is deliberate: telling the script why would itself leak information about the other origin.

      Where to keep the key

      Bearer tokens, cookies and theft

      Many single-page apps skip session cookies and hold a bearer token instead (often an OAuth access token or a JWT), sending it in an Authorization: Bearer … header. "Bearer" is literal: RFC 6750 says anyone holding the token can use it, no further proof needed. Where the app keeps that token decides what goes wrong.

      ApproachCSRF exposureIf an attacker gets script on your origin (XSS)
      Token in localStorageNone by itself: the browser never attaches it automatically.Script reads the token and sends it anywhere. The attacker keeps using it from their own machine until it expires.
      HttpOnly session cookieYes, so add SameSite, tokens and Origin checks.Script cannot read the cookie, but can make requests that carry it while the page is open. Damage is real but tied to the victim's browser session.
      Token held by a server-side "backend for frontend", browser has only an HttpOnly cookieYes, defended as above.As for cookies. The access token never reaches the browser at all.
      Sender-constrained token (DPoP, RFC 9449)None by itself.A copied token is useless without the private key that signs each request. Keys kept non-extractable resist theft, though script on the page can still ask for signatures while it runs.

      Neither choice survives XSS untouched, so the real decision is about what XSS can take away with it. An HttpOnly cookie limits the attacker to acting through the victim's open browser; a readable token lets them walk off with the credential. For most web apps the conservative pattern is an HttpOnly, Secure, SameSite session cookie with a __Host- name, plus CSRF defenses. DPoP (Demonstrating Proof of Possession) binds a token to a key pair: the client signs a small proof for each request, covering the method and URL, and the server checks that the key matches the one the token was issued to. Zero-trust access built on tokens like these is Chapter 11.

      The invisible frame

      Clickjacking and frame-ancestors

      The same-origin policy lets any page embed yours in an iframe. It cannot read the frame, but it can make it invisible and lay it exactly under a button of its own, so that a click meant for "Play video" lands on your "Confirm" button, with the victim's cookies (if SameSite=None) doing the rest. This is clickjacking (CWE-1021, "improper restriction of rendered UI layers").

      The fix is a response header saying who may frame you. The modern form is the Content Security Policy directive frame-ancestors; the older X-Frame-Options header still helps very old browsers. SameSite=Lax or Strict cookies add another layer, because a cross-site iframe is not a top-level navigation and does not get them.

      Content-Security-Policy: frame-ancestors 'self'
      X-Frame-Options: DENY
      Putting it together

      A session setup you can defend

      • Session cookie: __Host- name, Secure, HttpOnly, SameSite=Lax (Strict for admin consoles), no Domain, Path=/.
      • Session IDs: at least 64 random bits from a secure generator (128 is easy); rotated at login and privilege change; idle and absolute timeouts; deleted server-side at logout.
      • Unsafe methods: no state change on GET; framework CSRF tokens on; reject unsafe requests whose Origin is foreign; a Fetch Metadata policy as an extra layer.
      • CORS: none unless you need it; an exact allowlist when you do; credentials only for origins you fully trust; never reflect, never suffix-match, never allow null; always Vary: Origin when the answer depends on Origin.
      • Framing: frame-ancestors 'self' (or 'none').
      • Detection: log 403s from your CSRF and Origin checks, with the Origin and Sec-Fetch-Site values. A spike of rejected cross-site POSTs is a forged-request campaign hitting your defenses, worth knowing about even when the defenses hold. Logging and detection are Chapter 17.
      Go deeper: subdomains are part of your attack surface

      Several protections here draw their line at the site, not the origin: SameSite, Sec-Fetch-Site: same-site, and any cookie set with Domain=. Every host under your registrable domain is therefore inside the fence. A forgotten staging host, a user-content subdomain, or a DNS record still pointing at a cloud resource you deleted can become a launch point for same-site requests or for planting cookies on the parent domain. Keep an inventory of subdomains, remove stale DNS records, put user-uploaded content on a separate registrable domain, and use __Host- cookies so a sibling cannot overwrite your session cookie.

      Where this comes from

      Sources