Cross-Site Request Forgery
The browser attaches credentials to requests automatically, including ones another site caused. That helpfulness is the vulnerability, and it is why the defence has to be explicit.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why can another site make my API call succeed, and which defence matches my architecture?
Someone stays signed in so they do not have to log in again. Every product they use is built on that expectation.
Our API only accepts JSON and requires a session, so a random web page cannot call it. And if it could, the same-origin policy would stop it.
The same-origin policy withholds the *response*. It does not stop the request being sent, and for a state-changing endpoint the response is often irrelevant to the attacker (The Same-Origin Policy).
- The same-origin policy withholds the *response*. It does not stop the request being sent, and for a state-changing endpoint the response is often irrelevant to the attacker (The Same-Origin Policy).
- The browser attaches the cookie because the cookie belongs to your site, not because your page made the request. From the browser's point of view a form post from another page to your origin is a completely ordinary navigation.
- "We only accept JSON" is a real defence only if the server actually enforces it. A cross-site form can send
text/plainorapplication/x-www-form-urlencodedwith no preflight at all, and a lenient body parser will read it (CORS). - A GET that changes state is directly reachable from an
<img src>in an email or a forum post, with cookies attached and no user interaction whatsoever (GET: The Promise of Safety). - The defence you need depends on how you send credentials, and most teams cannot immediately say which of the two they are doing (Cookies vs Script-Readable Tokens).
What is actually happening
In the browser, not in the framework.
- The browser sends ambient credentials: cookies, and in some configurations TLS client certificates or platform authentication, attached by the browser according to the destination rather than the origin of the code that triggered the request.
- A cross-site page can therefore cause an authenticated request — through a form submission, an image or script tag, a navigation, or a
fetchwithcredentials: "include"— without ever reading the answer. - `SameSite` is the platform's structural answer:
Strictwithholds the cookie on every cross-site request,Laxallows it on top-level navigations that are safe methods, andNonesends it always and requiresSecure. Laxis the important one to understand precisely: it permits the cookie on a top-level GET navigation, which keeps ordinary inbound links working, and withholds it on cross-site POSTs, iframes, images and script-initiated requests. That closes the classic form-post case by default.- A CSRF token is a value the server generated and the page proves it knows — via a header or a body field. A cross-site page cannot read it, because reading it would require crossing the same-origin boundary.
- Origin and Referer checks work from the other direction: the browser sets
Originon requests it considers cross-site, and the server compares it against an allowlist.Originis set by the browser and cannot be forged by page script. - Bearer tokens sent in an `Authorization` header do not have this problem — the header is added by your code, so another site's page cannot cause it. They have a different problem, which is where that token lives (Cookies vs Script-Readable Tokens).
What this makes the browser do
And which of it is avoidable.
- Cookie matching on every request: domain, path,
Secure,SameSite, and the request's site relationship, evaluated before the request is queued. - Preflighting requests that a custom header or a non-simple content type makes non-simple — which is the mechanism behind the "custom header" defence, since a cross-site page cannot get past the preflight (CORS).
- Partitioning, increasingly, so that cookies in a third-party context are keyed by the embedding site as well — which changes the shape of this problem for embedded widgets (Storage Security and Durability).
- Avoidable work: sending a large session cookie on every static asset request because
Pathwas never scoped. It is not a security failure; it is bytes on every request in the waterfall (Reading a Network Waterfall).
The request the browser was happy to make
The whole class fits in one picture: a page you do not control causes a request to a site you do control, and the browser — behaving exactly as specified — attaches the credentials it holds for the destination. No boundary was crossed in the browser's view, because the credential belongs to the destination and not to the caller.
The attacker never sees the response, and for a state-changing endpoint that costs them nothing. This is precisely why the same-origin policy is not the defence: it governs reading, and reading is not what was wanted.
- The credential is attached because it belongs to *your* site. The initiating page is irrelevant to that decision.
- A GET that changes state needs no form at all — an
<img src>in an email is enough. - The response being unreadable is not a defence when causing the action was the goal.
- Injected script in your own origin bypasses every defence below, because it is same-site and can read your token (Cross-Site Scripting).
Which defence applies to which architecture
This is the part that gets skipped, and it is why so many teams ship a defence that does not fit. Work out how credentials reach your server first; the correct answer follows almost mechanically from that.
Note the last row. A team that has moved to Authorization-header tokens and then builds a CSRF token system has spent effort on a category it no longer has, while leaving the risk it does have — token storage — unaddressed.
| How credentials travel | Is it forgeable cross-site? | What defends it | The mistake this architecture invites |
|---|---|---|---|
| Session cookie, same-site API | Yes — this is the classic case | SameSite=Lax or Strict, plus a token, plus an Origin check | Assuming the browser default is set, and never setting the attribute explicitly |
Session cookie, cross-site API (SameSite=None) | Yes, and the structural layer is gone by definition | A token is now load-bearing, with a strict Origin allowlist | Setting None for one embedded widget and losing the defence application-wide |
Bearer token in an Authorization header | No — your code adds the header, another page cannot | Nothing extra; the risk moved to storage | Building a CSRF token anyway, while the bearer token sits in localStorage (Cross-Site Scripting) |
| Cookie plus a required custom request header | No, if the server requires the header | The preflight — a cross-site page cannot get one approved (CORS) | Servers that treat the header as optional, which reopens it silently |
| Basic auth or a TLS client certificate | Yes — fully ambient, attached by the browser | An Origin check; SameSite does not apply to either | Forgetting these exist because they predate the discussion |
Choosing the token pattern
Once you know a token is needed, there are three shapes in common use and they differ in what state the server keeps and what they assume about your subdomains. The client-side work is nearly identical in all three, which is why the decision usually gets made by whoever wrote the middleware.
What is the server willing to remember, and who else can write your cookies?
when The server already has per-session storage and you want the strongest binding between token and session.
cost Session state to store and expire, and a token that must be refreshed in long-lived tabs (Long-Lived Clients and Version Skew).
when You want a stateless server: the token is in a readable cookie and echoed in a header, and the two must match.
cost Rests on cookie integrity. A compromised or hostile subdomain can set cookies for the parent domain, which weakens it — bind the token to the session signature rather than trusting equality alone.
when A JSON API where the server strictly requires a header no simple cross-site request can carry.
cost The whole defence is the preflight, so it collapses the moment the server accepts a request without the header, or CORS is loosened (CORS).
when An internal tool with no inbound deep links, where arriving logged out from an external link is acceptable.
cost Every external link lands the user in a signed-out state, which is a product decision rather than a configuration one (Login Redirects and the Open-Redirect Trap).
How to build it
Most important first.
- Start by naming your credential mechanism. Cookie-based sessions need a CSRF story;
Authorization-header tokens do not, and pretending you are in the other case is how the wrong defence gets built (What the Frontend Is Responsible For in Auth). - Set
SameSiteexplicitly on every session cookie. Relying on a browser default means relying on a value that differs across browsers and is still changing (Cookies). - Never change state on a GET. It is an HTTP contract point long before it is a security one, and it is what makes an
<img>tag an attack surface (GET: The Promise of Safety). - For cookie sessions, layer a token on top of
SameSite: the double-submit pattern, or a server-side synchronizer token, sent as a request header your client adds. - Have the server check
Originon state-changing requests and reject unknown values. It is cheap, it needs no client change, and it catches the cases the other layers miss (The Trust Boundary). - Keep the token out of the URL. Query strings land in server logs, in
Refererheaders, in analytics and in shared links (Analytics Events That Answer a Question). - Make the client add the header centrally — one fetch wrapper, not eighty call sites — so that "did this request include the token" is a property of the transport rather than of each developer (The Life of a Fetch).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A CSRF failure usually surfaces as a form submission that silently does nothing. The error has to be announced and focus moved to it, or a screen-reader user has no way to know the submission failed (Errors People Can Actually Perceive).
- Token expiry is the common cause of that failure, and it hits slow users hardest — anyone using a screen reader, switch access or voice control takes longer to complete a form, so a short token lifetime is a disproportionate barrier (Session Expiry and the Refresh Race).
- A re-authentication interstitial triggered mid-form must preserve entered data and return focus to where the user was. Losing a long form is a much larger cost for someone who took twenty minutes to fill it (Form State Is a Draft).
- Never rely on a hidden field alone being present in the DOM as the user-visible state of the defence; the user needs to be told what happened in words, not by an invisible input (Live Regions and Announcement).
What can go wrong
- A defence that only guards some methods. A token checked on POST and not on PUT, PATCH or DELETE is a token checked on the endpoints someone remembered.
- A token that is not bound to the session, so any valid token works against any user — a check that passes review and stops nothing.
- A
SameSite=Nonecookie set to make an embedded widget work, quietly removing the structural defence for the entire application (Third-Party Scripts and the Supply Chain). - Login itself being forgeable: an attacker signs the victim into an account the attacker controls, and everything the victim then does is recorded in it. The login form needs the defence too.
- A logout endpoint on GET, so a
<img src="/logout">in any page signs your users out — low severity, high annoyance, and a reliable sign the GET rule is not enforced. - The mitigation failing: the token stored in
localStorageand read by the fetch wrapper, so an injection reads it trivially — the CSRF defence and the XSS blast radius are now coupled (Cross-Site Scripting).
- Token rotation racing an in-flight submission: the server rotates the token on a previous request while a form is still open, and the submission fails with a stale one. The client needs to refresh and retry once rather than surface a confusing error (Retries, and the Duplicate Order).
- Two tabs sharing a cookie jar: a login or logout in one changes the session under the other, so the second tab holds a token bound to a session that no longer exists (Auth Across Tabs).
- A deploy that changes the token mechanism reaches tabs that loaded the previous version at unpredictable times, so both shapes must be accepted during the rollout window (Deploying a Frontend).
- The browser enforces
SameSiteand setsOrigin; both are trustworthy because page script cannot alter them. Everything else in this lesson is a server-side check the client cooperates with. - The attacker never needs to read the response. Any endpoint where causing the action is the win — transfer, delete, change email, add an authorized device — is fully exploitable without a single byte coming back.
- CSRF and XSS compose badly in one direction: an injection in your own origin defeats every CSRF defence at once, because injected script can read the token and is same-site by definition (Cross-Site Scripting).
- A permissive CORS configuration with credentials turns a read restriction into a read permission, which converts some CSRF-shaped issues into full data exposure (CORS).
- Attack construction, chaining and the login-CSRF variant are Security Engineering territory; the frontend job is knowing which defence applies and making the client actually send it.
- "The same-origin policy prevents this." It prevents the other site *reading* your response. The request still happens, with credentials.
- "We use JSON, so we are fine." Only if the server rejects other content types. A cross-site form can post
text/plainwithout any preflight. - "HTTPS prevents CSRF." Transport security is unrelated. The forged request is a perfectly valid, correctly encrypted request.
- "
SameSite=Laxis the default now, so this is solved." Defaults differ between browsers and are still moving, andLaxstill permits top-level GET navigations. Set it explicitly and keep GETs safe. - "CSRF only matters for forms." Any credentialed state change is reachable, including ones triggered by a tag, a navigation or a script-initiated request.
- "An
HttpOnlycookie fixes it."HttpOnlystops script reading the cookie. The browser still attaches it, which is the entire problem.
Measuring it, and what changes in the field
- The Network panel shows which cookies were sent and which were withheld, with the reason — the fastest way to confirm what
SameSiteis actually doing (Debugging the Network). - The Application panel lists every cookie with its
SameSite,Secure,HttpOnly,DomainandPathvalues, which is worth reading once per application rather than never. - Server-side rejection counts for missing or invalid tokens tell you whether the defence is live and whether it is breaking real users; a rate of exactly zero usually means it is not being checked (The Metrics a Backend Must Emit).
- Form submission failure rates broken down by duration-on-page reveal token expiry as a usability problem before a user reports it (Real User Monitoring).
- In an embedded or third-party context your cookies may be partitioned or absent, so a defence built on cookies behaves differently inside someone else's page (Storage Security and Durability).
- For a native or hybrid client there is no cookie jar shared with a browser, and the ambient-credential premise mostly disappears — which is why mobile teams often find this discussion irrelevant and web teams cannot.
- For a long-lived single-page application, tokens rotate while the tab stays open, so the client needs to refresh its token rather than assume the one it loaded with is still valid (Long-Lived Clients and Version Skew).
- With multiple tabs open, one tab logging out or refreshing a session invalidates the token another tab is holding (Auth Across Tabs).
SameSite=Strictis the strongest and breaks inbound links: a user following a link from an email arrives logged out, which most products cannot accept (Login Redirects and the Open-Redirect Trap).- Synchronizer tokens require server-side state per session; double-submit avoids that state and depends on cookie integrity, which subdomains can undermine.
- Moving to
Authorization-header tokens removes CSRF as a category and moves the risk to where the token is stored, which is a trade rather than a win (Cookies vs Script-Readable Tokens). - A strict
Originallowlist is cheap and becomes a deployment coupling — every new frontend host is a server configuration change.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe mechanism — the browser attaching credentials by destination rather than by the origin of the code that triggered the request — is how every browser works and is not going to change.
- SPEC-EVOLVING
SameSitedefaults and third-party cookie behaviour are being changed on independent timetables by Chromium, Safari and Firefox, so an application relying on any default rather than an explicit attribute behaves differently per browser and per browser version. - BROWSER-SPECIFICThe two-minute exemption some browsers apply to freshly-set
Laxcookies on top-level POSTs is a compatibility behaviour, not a specified guarantee, and it exists in Chromium but not uniformly elsewhere — never design a flow that depends on it. - PLATFORM-SPECIFICA native or hybrid client has no shared cookie jar with the browser, so the ambient-credential premise mostly does not apply; the same backend therefore needs a defence for its web clients that its mobile clients never exercise.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — how to test that a defence is actually enforced on every state-changing route, rather than on the three routes a test happened to cover.