Parameter

Cross-site request forgery (CSRF)

Also known as

  • XSRF
  • Session riding

Cross-site request forgery (CSRF) is a vulnerability where a site the attacker controls makes a logged-in victim's browser send a state-changing request to another application, which accepts it because the browser attaches the victim's cookies automatically.

OWASP
A01:2021 Broken Access Control
Last reviewed

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-demo
HTTP/1.1 302 Found
Location: /settings/profile?saved=1

The 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:

BrowserCookie without SameSite is treated as
Chrome and Chromium-based EdgeLax, since Chrome 80 (enforced from 2020)
FirefoxNone; Lax-by-default exists only behind a preference
SafariNone; 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/delete is exploitable with a link.
  • Method override. Frameworks that honor a _method=POST parameter or X-HTTP-Method-Override header 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.example to api.app.example is 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-urlencoded or text/plain bodies, which a cross-site form can send without a preflight, and still authenticates by cookie.
  • CORS is misconfigured to reflect any Origin with Access-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.

  1. 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.
  2. 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.
  3. Change the method and content type. Try the action as a GET, with _method overrides, and as a form-encoded or text/plain body in place of JSON.
  4. Check Origin handling. Send Origin: https://attacker.example, then no Origin header, and see whether the server enforces either.
  5. Read the cookie attributes. Note SameSite, and test in Firefox or Safari when the cookie relies on Chrome's default.
  6. 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.
  7. 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 CsrfViewMiddleware by default. Keep it on, include {% csrf_token %} in forms, send the X-CSRFToken header from JavaScript, and audit every @csrf_exempt.
  • Rails turns on forgery protection by default in ActionController::Base since 5.2. Keep it enabled with the :exception strategy, and use csrf_meta_tags so JavaScript clients can send the token.
  • Express no longer has an official middleware: csurf is 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 using Sec-Fetch-Site with an Origin fallback:
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.

Written by Parameter · Last reviewed