How does CSRF work?
CSRF works because browsers attach cookies to requests based on the destination, not on the page that started the request. If a user is logged in to app.example and visits a page on attacker.example, that page can submit a form to app.example, and the browser may send the session cookie along with it.
The request the victim's browser sends when a hidden form on the attacker's page auto-submits:
POST /settings/profile HTTP/1.1
Host: app.example
Origin: https://attacker.example
Sec-Fetch-Site: cross-site
Content-Type: application/x-www-form-urlencoded
Cookie: session=user_a
display_name=csrf-demoHTTP/1.1 302 Found
Location: /settings/profile?saved=1The application saw a valid session cookie and saved the change. The attacker never saw the response and never needed to; the damage is the state change. CSRF only matters for actions: changing an email or webhook, adding an admin, transferring an object, deleting data.
Three conditions have to hold: the action is triggered by a request the attacker can predict in full, the application authenticates it only with credentials the browser sends automatically (cookies, HTTP Basic, client certificates), and there is no unpredictable value the attacker cannot supply.
Do SameSite cookies prevent CSRF?
Partly, and less uniformly than many teams assume. SameSite=Lax stops the browser from sending the cookie on cross-site POSTs and subresource requests, which blocks the classic auto-submitting form. SameSite=Strict blocks the cookie on all cross-site requests, including link clicks.
The default when a cookie sets no SameSite attribute depends on the browser:
| Browser | Cookie without SameSite is treated as |
|---|---|
| Chrome and Chromium-based Edge | Lax, since Chrome 80 (enforced from 2020) |
| Firefox | None; Lax-by-default exists only behind a preference |
| Safari | None; no Lax-by-default |
That is per MDN's browser compatibility data. Chrome's default also carries the "Lax+POST" exception documented by Chromium: a cookie without a SameSite attribute is still sent on a top-level cross-site POST if it is at most two minutes old, which reopens CSRF right after login.
Even with an explicit SameSite=Lax, the gaps testers look for are:
- State-changing GETs. Lax still sends cookies on top-level GET navigations, so
GET /api/projects/project_1043/deleteis exploitable with a link. - Method override. Frameworks that honor a
_method=POSTparameter orX-HTTP-Method-Overrideheader can turn an allowed GET into a routed POST. - Sibling subdomains. SameSite is scoped to the site, not the origin. A request from
blog.app.exampletoapi.app.exampleis same-site, so an XSS or subdomain takeover on any sibling bypasses it.
Treat SameSite as defense in depth, as the OWASP cheat sheet does, and keep an explicit defense on top.
When is an API not affected?
An API is not exposed to classic CSRF when it authenticates with a bearer token the client adds itself, such as Authorization: Bearer ... read from memory. A cross-site page cannot make the browser attach that header, and a cross-origin request with a custom header triggers a CORS preflight the API can refuse.
The exceptions are where teams get caught:
- The SPA stores the token in a cookie and the API accepts the cookie as authentication, as a fallback or for convenience. That API is back to cookie semantics.
- The API accepts
application/x-www-form-urlencodedortext/plainbodies, which a cross-site form can send without a preflight, and still authenticates by cookie. - CORS is misconfigured to reflect any
OriginwithAccess-Control-Allow-Credentials: true, which lets the attacker read responses and CSRF tokens.
What is login CSRF?
Login CSRF forges the login request itself, signing the victim into an account the attacker controls. Whatever the victim then enters (a saved card, an uploaded document, search history) lands in the attacker's account. Unauthenticated login forms are often left without CSRF protection for exactly this reason: nobody has a session to protect yet. The fix is a pre-session token on the login form and a fresh session ID after authentication.
How do you test for CSRF?
The goal is to show that a request built entirely by another origin, with no secret, changes state.
- List state-changing endpoints and how each is authenticated. Cookie or Basic auth means in scope; a header-only bearer token usually means out of scope, after you confirm no cookie fallback.
- Remove the token. Replay the request with the CSRF token parameter or header deleted, then with it empty, then with a token from a different session. Any success is a finding.
- Change the method and content type. Try the action as a GET, with
_methodoverrides, and as a form-encoded ortext/plainbody in place of JSON. - Check Origin handling. Send
Origin: https://attacker.example, then no Origin header, and see whether the server enforces either. - Read the cookie attributes. Note SameSite, and test in Firefox or Safari when the cookie relies on Chrome's default.
- Build a proof page on a separate origin that submits the request, and confirm the change with the victim account in a real browser. Burp Suite's CSRF PoC generator produces the HTML.
- Test login and logout for forced authentication.
How do you fix CSRF?
Require something a cross-site page cannot produce, and reject requests the browser labels as cross-site. Use your framework's built-in protection; it is almost always better than a hand-rolled token.
- Django enables
CsrfViewMiddlewareby default. Keep it on, include{% csrf_token %}in forms, send theX-CSRFTokenheader from JavaScript, and audit every@csrf_exempt. - Rails turns on forgery protection by default in
ActionController::Basesince 5.2. Keep it enabled with the:exceptionstrategy, and usecsrf_meta_tagsso JavaScript clients can send the token. - Express no longer has an official middleware:
csurfis deprecated. Use a maintained double-submit library or check Fetch Metadata directly. - Go 1.25 added
http.CrossOriginProtection, which rejects non-safe cross-origin browser requests usingSec-Fetch-Sitewith anOriginfallback:
mux := http.NewServeMux()
mux.HandleFunc("POST /settings/profile", saveProfile)
cop := http.NewCrossOriginProtection()
// Only if a separate trusted front end must post here:
// cop.AddTrustedOrigin("https://admin.app.example")
log.Fatal(http.ListenAndServe(":8080", cop.Handler(mux)))The same idea in Express, as middleware in front of state-changing routes:
function rejectCrossSite(req, res, next) {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
const site = req.get("Sec-Fetch-Site");
if (site && site !== "same-origin" && site !== "none") {
return res.status(403).json({ error: "cross-site request blocked" });
}
next();
}Then close the gaps: never change state on GET, set SameSite=Lax or Strict explicitly on session cookies, and fix any cross-site scripting, because script on your origin can read tokens and defeats every CSRF defense. Checking the Referer alone is fragile, since browsers and privacy tools strip it.
CSRF is listed under A01:2021 Broken Access Control in the OWASP Top 10. Unlike broken function-level authorization, where the attacker sends a request they should not be allowed to make, CSRF borrows a victim who is allowed to make it.
[ Sources ]
Written by Parameter · Last reviewed

