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

A CSRF attack: the browser is the confused deputyVictim's browserholds session cookie for bank.exampleevil.exampleattacker's page1. victim visits the attacker's page2. page contains an auto-submitting formBrowser sends POSTto bank.example/transfer3Cookie: session=... attachedif SameSite allows itbank.examplesees a valid session4. request arrivesTransfer executesunless a CSRF check rejects itSignals the server can checkSec-Fetch-Site: cross-site | Origin: https://evil.examplemissing or wrong CSRF token | cookie SameSite policyThe attacker never reads the cookie or the response; it only needs the side effect.
The attacker's page makes the victim's browser send a request; the browser attaches the session cookie, and only a server-side check of origin signals or a token stops the side effect.

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-Type accept 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.com is same-site with app.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=DELETE in 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

  1. List every state-changing endpoint, and move any state change off GET.
  2. Set SameSite=Lax (or Strict), Secure and HttpOnly explicitly on every session cookie.
  3. 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.
  4. Make JSON endpoints reject any content type other than application/json, and protect the login form.
  5. Build a cross-site attack page and a sibling-subdomain attack page and run them in CI against staging on every release.
  6. Review the remaining XSS risk, because it defeats every CSRF control.
Key takeaway: CSRF works because browsers attach cookies to requests that other sites cause, so the server cannot tell which page asked. CORS does not prevent it, because simple form requests are sent without preflight. Watch for JSON endpoints that accept text/plain, state changes on GET, login CSRF and same-site subdomains. Set SameSite explicitly, check Sec-Fetch-Site with an Origin fallback on every non-safe request, keep tokens where older browsers matter, and test with real cross-site and same-site attack pages.