The idea: your browser adds your cookie for you

Alice is logged in to her bank. The bank knows it is her because every request from her browser carries the session cookie it set at login. Then Alice opens another site. That site's page can contain a form whose action is the bank's transfer URL, and it can submit that form itself. The browser sends the request to the bank and, by the old rules, attaches the bank's cookie, because the request goes to the bank. The bank sees a valid session and moves the money. This is cross-site request forgery (CSRF): the attacker never sees the cookie or the response, it only makes Alice's browser send a request she did not mean to send.

Alice's browser holds the bank's session cookie. She opens evil.example, whose hidden form posts to bank.example/transfer. The browser sends the POST with the cookie attached, and the bank, seeing a valid session, transfers 100 dollars to mallory
The attacker's page only triggers the request; the browser adds Alice's cookie, and the bank cannot tell a forged request from a real one.

The canvas shows Alice's browser (the sites she visits, the open tab, the page's code, her cookie jar and the browser's cookie decision), the request and response on the wire, and the bank's server (its session store, its checks and Alice's account). The table at the bottom keeps every request. Pick a request and press Send; the two selects change the cookie's SameSite attribute and the bank's own defence.

Why it works

Two things make CSRF possible. Cookies are ambient credentials: the browser attaches them automatically, based on where the request goes, not on which page started it. And some requests are allowed across sites without any permission: a form submission, a link, an image. The same-origin policy stops the attacker's page from reading the bank's answer, but not from sending the request (Demo 1: no defence, the form moves $100). CORS does not help either: it controls reading responses, and a plain form POST needs no preflight (see CORS and the Preflight Request).

SameSite: the browser leaves the cookie out

The SameSite cookie attribute tells the browser when to attach the cookie to a request that comes from another site:

valuecross-site form POSTcross-site link (top-level GET)same-site request
None (needs Secure)sentsentsent
Laxnot sentsentsent
Strictnot sentnot sentsent
not set (Chrome)like Lax, but sent during the cookie's first 2 minutessentsent

With Lax the hidden form arrives without the cookie, so the bank treats it as an anonymous visitor (Demo 2). But Lax still sends the cookie on a top-level GET navigation, because that is what following a link is. A bank that changes state on GET /transfer?to=… is still exploitable with a simple link (Demo 3). The rule behind it: never change state on GET.

Strict stops the link too, but it also drops the cookie when Alice follows a genuine link from her email, so she arrives logged out (Demo 4). A common setup is a Lax session cookie plus a Strict cookie that sensitive actions require.

Browsers now treat a cookie with no SameSite attribute as Lax. Chrome makes one exception: for the first two minutes after the cookie is set, a top-level cross-site POST still carries it, so login flows that POST back from an identity provider keep working. Inside that window the hidden form works again (Demo 5: no SameSite: 2-minute Lax+POST). Set the attribute explicitly.

Site is not origin

An origin is scheme + host + port: https://blog.bank.example and https://bank.example are different origins. A site is the scheme plus the registrable domain (eTLD+1): both are the site bank.example. SameSite looks at sites, so a request from any subdomain is same-site and carries the cookie even with Strict. If an attacker can put a page on a subdomain (a user blog, a forgotten staging host, a subdomain takeover), SameSite does not protect the bank at all (Demo 6: a same-site attacker (subdomain)).

Three page addresses compared with https://bank.example. https://bank.example/transfer is the same origin and the same site. https://blog.bank.example is a different origin but the same site, so SameSite cookies are still sent. https://evil.example is a different origin and a different site, so SameSite Lax or Strict cookies are not sent on a POST
SameSite compares sites (registrable domains), not origins, so a subdomain is inside the cookie's boundary.

The server's own defences

SameSite is a browser feature; the server should check too. The classic defence is a synchronizer token: the bank stores a random csrf_token in the session and writes it into a hidden field of its own forms. A state-changing request must send it back. The attacker's page cannot read it, because reading the bank's pages from another origin is exactly what the same-origin policy forbids, and that includes a subdomain (Demo 7: a CSRF token stops every forgery). The bank's own form carries the token and still works.

A double-submit cookie is the stateless variant: the token is in a cookie and must also appear in the request body; the server only compares the two. It is weaker against a subdomain attacker, who may be able to set a cookie for the whole site (cookie tossing), unless the token is signed and bound to the session.

An Origin check is cheaper: browsers send an Origin header on every cross-origin POST, and the server refuses state-changing requests whose origin is not its own (falling back to Referer when Origin is absent). Because it compares exact origins, it stops the subdomain attacker that SameSite let through (Demo 8). Newer browsers also send Sec-Fetch-Site: cross-site / same-site / same-origin, which the request panel shows; a server can refuse anything but same-origin for state changes.

defencewherestops cross-site formstops same-site subdomaincost
SameSite=Laxbrowseryes (POST)nostate-changing GETs stay open
SameSite=Strictbrowseryesnolinks from other sites arrive logged out
synchronizer tokenserveryesyesa token in every form and AJAX call
Origin / Referer checkserveryesyesmust handle a missing header
A matrix of the three forged requests against the defences. With SameSite None and no server check, all three attacks work. SameSite Lax blocks the cross-site POST but not the cross-site GET link or the subdomain POST. SameSite Strict blocks both cross-site attacks but not the subdomain. A CSRF token or an Origin check blocks all three
Each browser setting leaves some request through; a server-side token or Origin check closes all three.

What is and is not affected

CSRF needs ambient credentials. An API that only accepts a token in an Authorization header (a bearer JWT kept in JavaScript) is not exposed to it, because another site cannot make the browser add that header; it trades CSRF for a larger XSS risk instead (see JWT vs Session Cookies). And any cross-site scripting bug defeats every CSRF defence, because script running on the bank's own origin can read the token and send same-origin requests. OAuth's state parameter is the same idea as a CSRF token, applied to the login redirect (see OAuth). Cookie attributes in general are on How Cookies Work.

See also HttpOnly Cookie vs localStorage: a token in localStorage is never sent by a cross-site form, but an XSS script can read it.

See also SSRF: the server-side cousin of CSRF, where the attacker borrows the server's network position instead of the user's cookies.

What the page leaves out

JSON APIs that only accept application/json (a form cannot send it without a preflight), login CSRF, clickjacking, the Public Suffix List that defines sites, schemeful same-site (http and https are different sites), how each browser rolled out Lax by default, and frameworks' built-in token handling.