Cross-site request forgery (CSRF) makes a victim's browser send a state-changing request to a site where the victim is logged in, from a page the attacker controls. The attacker never sees the victim's cookies and never reads the response. They do not need to: the browser attaches the cookies automatically, the server sees a correctly authenticated request, and the money moves, the email address changes or the admin account is created.
This article explains CSRF from the attacker's side. You will learn exactly what the browser attaches and when, the difference between a site and an origin, why CORS does not stop the attack, the variants that slip past common defences (login CSRF, JSON endpoints, GET side effects and same-site subdomains), how SameSite cookies and Fetch Metadata headers change the picture, and how to test your own application. For the layered defence architecture, read the companion CSRF defence architecture article.
Architecture at a glance
From first principles: ambient authority
CSRF is possible because of ambient authority: credentials the browser attaches to a request on its own, based on the destination, regardless of which page started the request. Cookies are the main example; HTTP Basic credentials and client TLS certificates behave the same way. The server sees the credential and concludes the user meant to act, but the credential proves only who the browser belongs to, not which page asked.
Three conditions must all hold for an attack to work. First, the action is triggered by a request the attacker can cause a browser to send: a form submission, an image load, a navigation or a script-initiated fetch. Second, the server authenticates that request using only ambient credentials. Third, every parameter is predictable, so the attacker can write the request in advance. Remove any one and the attack fails, which is exactly how each defence works: Fetch Metadata and Origin checks attack the first condition, SameSite cookies and header-based tokens the second, and an unpredictable CSRF token the third.
Two vocabulary terms matter. An origin is scheme, host and port: https://app.example.com:443. A site is scheme plus registrable domain: https://example.com covers app.example.com and blog.example.com. SameSite cookies are about sites; CORS and Fetch Metadata's same-origin value are about origins. A request from blog.example.com to app.example.com is cross-origin but same-site, and SameSite cookies do not stop it.
Why CORS does not stop CSRF
A common belief is that CORS protects against CSRF. It does not, because CORS controls whether a page may read a cross-origin response, not whether the request is sent. Browsers send so-called simple requests without any preflight: methods GET, HEAD or POST, with a content type of application/x-www-form-urlencoded, multipart/form-data or text/plain, and no custom headers. An HTML form can produce exactly these, and forms have been able to post across origins since long before CORS existed. The request reaches your server, your handler runs, and only the response is hidden from the attacker.
Preflight does protect requests the attacker cannot make simple: a PUT or DELETE, a Content-Type: application/json header, or a custom header like X-CSRF-Token. If your server answers the preflight without permitting the attacker's origin, the browser never sends the real request. That is why requiring a custom header on every state-changing API call is a real defence, provided the server rejects requests that lack it rather than tolerating them.
Attack variants that break real applications
The textbook attack is an auto-submitting form. The variants are what break real applications, because each one slips past a defence that someone assumed was complete.
<!-- 1. Classic form CSRF: auto-submits as soon as the page loads -->
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById("f").submit()</script>
<!-- 2. JSON CSRF against a server that parses any body as JSON.
enctype=text/plain sends name=value verbatim, so the body becomes:
{"to":"attacker-account","amount":5000,"x":"="} -->
<form action="https://api.bank.example/transfer" method="POST" enctype="text/plain">
<input name='{"to":"attacker-account","amount":5000,"x":"' value='"}'>
</form>
<!-- 3. GET with a side effect: no form, no script, just an image tag -->
<img src="https://bank.example/account/delete?confirm=yes" alt="">- JSON endpoints. An API that expects JSON is safe only if it rejects any other content type. Frameworks that parse the body regardless of
Content-Typeaccept the text/plain form above, which sends no preflight. - GET side effects. Token middleware usually exempts GET, so a convenience endpoint that changes state on GET is unprotected, and SameSite=Lax still sends cookies on top-level GET navigations.
- Login CSRF. The attacker submits the login form with their own credentials, so the victim is silently logged into the attacker's account. Anything the victim then saves, such as a card number, search history or uploaded file, goes to the attacker. Login forms need CSRF protection even though no session exists yet.
- Same-site attackers. A compromised or user-content subdomain such as
uploads.example.comis same-site withapp.example.com, so SameSite cookies are sent. Origin and Fetch Metadata checks still catch it, because it is cross-origin. - Method override. Frameworks that honour
_method=DELETEin a POST body turn a simple form into a destructive request.
SameSite cookies: what they block and what they do not
The SameSite cookie attribute tells the browser when to attach a cookie to requests initiated by other sites. Strict never sends it cross-site, even on a link click, which breaks flows like arriving from an email link already logged in. Lax sends it on top-level navigations using safe methods such as GET, but not on cross-site POST forms, iframes or image loads. None sends it everywhere and requires the Secure attribute.
Lax stops the classic POST form and is a strong baseline, but it is not a complete defence. It does not cover same-site attackers, it allows GET side effects, and browser defaults differ: Chrome treats cookies with no SameSite attribute as Lax, with a short grace period after a cookie is set during which top-level cross-site POSTs still carry it, while other browsers have not all adopted the same default. Set SameSite explicitly on every session cookie rather than relying on any browser's default, and treat it as one layer, not the defence.
Fetch Metadata: the server-side origin check
Modern browsers attach Fetch Metadata request headers to every request to an HTTPS origin. The one that matters for CSRF is Sec-Fetch-Site, whose value is same-origin, same-site, cross-site or none (a user-initiated navigation such as a typed URL or bookmark). Page script cannot set or forge it, so a server can reject a state-changing request whose value is not same-origin or none. The Origin header provides a fallback for browsers without Fetch Metadata. The OWASP CSRF cheat sheet describes Fetch Metadata checks alongside token-based defences, and Go 1.25 added it to the standard library.
# Framework-neutral Fetch Metadata check (Python, WSGI-style environ)
SAFE = {"GET", "HEAD", "OPTIONS"}
TRUSTED_ORIGINS = {"https://admin.example.com"} # explicit cross-origin callers
def csrf_allowed(environ):
if environ["REQUEST_METHOD"] in SAFE:
return True # safe methods must not change state
site = environ.get("HTTP_SEC_FETCH_SITE")
origin = environ.get("HTTP_ORIGIN")
if origin in TRUSTED_ORIGINS:
return True
if site is not None:
return site in ("same-origin", "none") # "none" = user typed URL / bookmark
if origin is not None: # older browser: compare Origin to Host
host = environ.get("HTTP_HOST", "")
return origin.split("://", 1)[-1] == host
return True # no browser signals: non-browser client; rely on token or auth header// Go 1.25+: the standard library ships the same check
mux := http.NewServeMux()
mux.HandleFunc("POST /transfer", transferHandler)
cop := http.NewCrossOriginProtection()
if err := cop.AddTrustedOrigin("https://admin.example.com"); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", cop.Handler(mux)))Go's CrossOriginProtection follows this logic: it always allows GET, HEAD and OPTIONS; allows Sec-Fetch-Site values same-origin and none; rejects other values (including same-site) unless the request's origin was added with AddTrustedOrigin or its path matches AddInsecureBypassPattern; falls back to comparing Origin with Host; and allows requests carrying neither header, on the assumption that they are same-origin or not from a browser. That last rule is the important caveat: header checks protect browsers, so non-browser clients must still authenticate with something that is not ambient.
Worked example: three findings on one dashboard
A team runs a server-rendered dashboard at app.example.com with cookie sessions, a JSON API at the same origin used by the dashboard's own JavaScript, and a marketing site at www.example.com that hosts user-submitted landing pages. A penetration test finds three issues.
First, POST /api/settings parses JSON regardless of content type, and the session cookie has no SameSite attribute, so in browsers that do not default to Lax a text/plain form from any site changes a user's notification email. Second, GET /invite/accept?id= adds the user to a team as a side effect. Third, a landing page on www.example.com can post forms to the dashboard, and SameSite would not stop it because the two hosts are the same site.
The fixes, in the order they shipped: set SameSite=Lax; Secure; HttpOnly on the session cookie; reject any API request whose content type is not application/json; change the invite flow to a confirmation page that submits a POST; and add a Fetch Metadata check that rejects non-safe requests unless Sec-Fetch-Site is same-origin or none, which closes the same-site landing-page hole. The server-rendered forms kept their synchronizer tokens as a second layer. A regression test now replays each attack page in a headless browser on every release and asserts a 403.
Testing your application for CSRF
- Inventory state-changing endpoints, including GET routes with side effects, login, logout, and any route that honours a method-override parameter.
- Replay each one cross-site. Host a test page on a different origin and on a sibling subdomain, submit the request as a form (urlencoded, multipart and text/plain), and confirm the server rejects it.
- Strip the defence. Remove the token, send an empty token, send another user's token, and drop the custom header; each must fail.
- Check cookie attributes in the browser's developer tools: every session cookie needs an explicit SameSite value plus Secure and HttpOnly.
- Check non-browser paths. Requests with no Origin and no Sec-Fetch-Site pass header checks by design, so confirm those clients authenticate with a header credential rather than the session cookie.
Failure modes
- Relying on CORS. CORS hides responses; it does not stop simple requests from being sent and processed.
- Token checked only when present. Middleware that validates a token if one is sent, but accepts requests without one, protects nothing.
- Tokens leaked into URLs. Tokens in query strings end up in logs, referrers and browser history.
- XSS on the same origin. Script running on your origin can read tokens and send same-origin requests, so no CSRF defence survives it; see cross-site scripting and Content Security Policy.
- Exempting the login form. Login CSRF needs no existing session, so the login endpoint needs the same check.
Trade-offs
Fetch Metadata checks need no state, no template changes and no client code, which makes them the cheapest broad defence, but they rely on modern browsers and say nothing about non-browser clients. Synchronizer tokens work everywhere and survive missing headers, at the cost of session state and template plumbing. SameSite cookies are free to enable and block most cross-site attacks, but not same-site ones. Bearer tokens in an Authorization header avoid ambient authority entirely and move the risk to token storage, where XSS becomes the threat; the session management guide compares the options. Most cookie-based applications should combine an explicit SameSite attribute with a Fetch Metadata or Origin check, and keep tokens on forms where the threat model includes older browsers.
What to do next
- List every state-changing endpoint, and move any state change off GET.
- Set
SameSite=Lax(or Strict),SecureandHttpOnlyexplicitly on every session cookie. - Add a Fetch Metadata check with an Origin fallback for all non-safe methods, using your framework's built-in support where it exists, such as Go's
http.CrossOriginProtection. - Make JSON endpoints reject any content type other than
application/json, and protect the login form. - Build a cross-site attack page and a sibling-subdomain attack page and run them in CI against staging on every release.
- Review the remaining XSS risk, because it defeats every CSRF control.