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.
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.
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.
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-a | yes | Top-level navigation. Lax cookies for site-a travel with it. |
| submit a form to site-a | yes, sends | The request is delivered. The page cannot read the reply. This is the root of CSRF. |
| embed site-a's image, script, iframe | yes, embeds | Content shows, but the page cannot read pixels, source or the frame's document. Framing is what clickjacking abuses. |
| fetch() site-a and read the body | no, unless CORS | The request may well be sent; the browser withholds the response unless site-a opts in with CORS headers. |
| read site-a's cookies or storage | no | document.cookie and localStorage are partitioned by origin (cookies by domain). |
| reach into a site-a iframe's DOM | no | Cross-origin frames talk only through postMessage, which should check the sender's origin. |
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.
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.
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")
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.
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.
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:
GET /delete?id=7 endpoint is defenseless.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.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
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.
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.
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.
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 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.
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.
| Approach | CSRF exposure | If an attacker gets script on your origin (XSS) |
|---|---|---|
| Token in localStorage | None 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 cookie | Yes, 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 cookie | Yes, 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 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
__Host- name, Secure, HttpOnly, SameSite=Lax (Strict for admin consoles), no Domain, Path=/.Origin is foreign; a Fetch Metadata policy as an extra layer.null; always Vary: Origin when the answer depends on Origin.frame-ancestors 'self' (or 'none').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.
frame-ancestors); Mozilla, Public Suffix List.