In short

Reject state-changing requests that a browser sent on another site's behalf. In current browsers, check Sec-Fetch-Site and allow only same-origin or user-initiated none; otherwise require an exact Origin match. Add a session-bound token for older browsers: a synchronizer token or an HMAC-signed double-submit cookie. Use __Host-, Secure cookies with an explicit SameSite, keep GET free of side effects, and require application/json on JSON endpoints.

  • A cookie proves who is signed in, not that the user meant to send this request.
  • Sec-Fetch-Site is the primary signal in current browsers; fall back to an exact Origin comparison, and do not treat a missing header as same-origin on sensitive routes.
  • Bind tokens to the session; the naive double-submit cookie fails when a sibling subdomain can set cookies.
  • Set SameSite explicitly: only Chromium-based browsers treat a cookie without it as Lax.
  • JSON endpoints must reject text/plain and form content types, which browsers send cross-site without a preflight.
  • Go 1.25 and later ship http.CrossOriginProtection; most other frameworks ship token middleware.

Verify it

  1. Submit a cross-site form carrying only the session cookie and confirm the stored value is unchanged.
  2. Send the request with Sec-Fetch-Site: cross-site, with same-site, and with no Fetch Metadata but a foreign Origin; all must be rejected.
  3. Replay a token from another user's session and confirm rejection.
  4. POST the JSON body as text/plain and confirm a 415 or 403 with no change.
  5. Search for GET routes that change state and for blanket CSRF exemptions on API prefixes.
Applies to

Cookie-authenticated state changes. Non-browser token APIs and cross-site integration need separately scoped policies.

Part of
Learning path: Browser and API boundaries (step 1 of 18). Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 27 minutes: 8 to read and 19 for the exercise

What you will learn

  • Explain why a cookie and CORS denial do not establish intent.
  • Apply session-bound token and origin rules precisely.
  • Test mutation state across legitimate and forged requests.

Before you start

Web developers protecting cookie-authenticated forms and APIs, and reviewers checking CSRF defenses.

  • Know when a browser attaches cookies automatically and how Origin differs from a user identity.

Refresh first: Session management: cookies, timeouts, rotation and revocation

Go to the guided practice

A forged cross-site POST against the intent checks

Other siteTrusted originAttacker's pageAlice's browserportal.example.testAuto-submitting form to/account/email1Lax or Strict cookiewithheld cross-site2POST, Sec-Fetch-Site:cross-site2Not same-origin or none:403, no write3No Fetch Metadata: Originmust match exactly3Same-origin POST with sessiontoken4Session, origin and tokenmatch4200: email updated4Trust boundaryUntrusted input1Step, described below
  1. Cross-site formA page on https://other.example.test makes Alice's browser submit a POST to /account/email.
  2. Cookie policyA SameSite=Lax or Strict session cookie is left off this cross-site POST; a SameSite=None cookie, or a request from a same-site sibling, still carries it.
  3. Intent checkWith no session cookie the handler answers 401. If a cookie arrives anyway, Sec-Fetch-Site: cross-site, or with no Fetch Metadata an Origin other than https://portal.example.test, gets 403 before the token check or any write.
  4. Legitimate requestThe portal's own page sends Sec-Fetch-Site: same-origin, the trusted Origin and the session-bound token, so the change is applied.
SameSite narrows which requests carry the cookie but does not cover same-site siblings or SameSite=None cookies, so the server decides from Sec-Fetch-Site, Origin and the token. A missing header is not evidence of same origin.

Explain why a valid cookie does not prove user intent

Suppose Alice is signed in to portal.example.test. A page elsewhere causes her browser to submit an email-change form. Depending on cookie policy and site context, the browser may attach Alice's session cookie automatically. The server therefore learns that the request carries Alice's credential, but not that Alice chose the action. An attacker does not need to read the response to change the stored email. This is why a CORS error in browser tools does not prove a mutation was prevented.

The example below deliberately assumes cookies are sent so the server-side rule can be evaluated independently. SameSite cookies reduce many cross-site request paths, but a sibling origin can be same-site while still outside your trusted application origin. Chromium-based browsers treat a cookie without a SameSite attribute as Lax, but other browsers do not all apply that default, so set it explicitly. A state-changing GET is especially problematic because links and navigation can reach it. Keep safe methods free of mutations and apply the request-intent policy to every cookie-authenticated change, including alternate methods, content types and legacy endpoints.

Bind the action to a session-specific secret and origin policy

Use the framework's supported CSRF mechanism for its session-backed routes. Choose the pattern by architecture. A stateful application can use a synchronizer token: the server stores an unpredictable token with the session, supplies it to the page and checks it on every unsafe request. A stateless backend can use a signed double-submit cookie: an HMAC over the session id and a random value, set in a cookie the page reads and echoes in a header, and validated against the session. OWASP calls the unsigned variant, which only compares the cookie with the header, insecure, because an attacker who can set a cookie from a sibling subdomain can set both. For users on current browsers, the Sec-Fetch-Site request header is now the primary signal, with an exact Origin comparison as the mandatory fallback when it is absent.

For this worked policy, require an exact trusted Origin of https://portal.example.test and a matching session token on email changes. Reject a missing Origin in the exercise; real framework integrations may implement a documented Referer fallback and proxy handling that you must preserve deliberately. Scheme, host and port all participate in origin comparison. Do not use an arbitrary Forwarded or X-Forwarded-Host value to redefine the trusted external origin. Behind a proxy, trust forwarded metadata only from configured trusted infrastructure.

The module below combines those checks for the exercise policy. Safe methods pass because they must never mutate. For unsafe methods it requires a session, rejects Sec-Fetch-Site values other than same-origin and none, falls back to an exact Origin match when the header is missing, and validates a signed, session-bound token with timingSafeEqual. Its test file covers cases A to F from the exercise and passes with node --test. Deliver the session in __Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax; the __Host- prefix forbids a Domain attribute, so a sibling subdomain cannot overwrite it.

JSON endpoints need one more rule. A browser sends application/x-www-form-urlencoded, multipart/form-data and text/plain cross-site without a CORS preflight, so an API whose JSON parser ignores the Content-Type header accepts a forged form or fetch that posts JSON as text/plain. Require Content-Type: application/json and reject the rest; the json option in the module returns 415 for them.

Fixedcsrf.mjsJavaScript
// csrf.mjs (Node.js 22+): request-intent checks for cookie-authenticated, state-changing routes.
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); // these must never change state

const mac = (secret, sessionId, random) =>
  createHmac('sha256', secret)
    .update(`${sessionId.length}!${sessionId}!${random.length}!${random}`)
    .digest();

// Signed double-submit token bound to the session; send it in a __Host- cookie the page reads.
export function issueToken(secret, sessionId) {
  const random = randomBytes(32).toString('hex');
  return `${mac(secret, sessionId, random).toString('hex')}.${random}`;
}

function tokenMatchesSession(secret, sessionId, token) {
  const [signature, random] = String(token ?? '').split('.');
  if (!signature || !random) return false;
  const given = Buffer.from(signature, 'hex');
  const expected = mac(secret, sessionId, random);
  return given.length === expected.length && timingSafeEqual(given, expected);
}

export function checkIntent({method, headers, session}, {trustedOrigin, secret, json = false}) {
  if (SAFE_METHODS.has(method)) return {ok: true};
  if (!session) return {ok: false, status: 401, reason: 'no_session'};
  const site = headers['sec-fetch-site'];
  if (site !== undefined) {
    // Fetch Metadata is the primary signal in browsers that send it.
    if (site !== 'same-origin' && site !== 'none') return {ok: false, status: 403, reason: 'cross_site'};
  } else if (headers.origin !== trustedOrigin) {
    // Fallback: an exact Origin match; a missing Origin is not evidence of same origin.
    return {ok: false, status: 403, reason: 'origin'};
  }
  if (json && !/^application\/json\s*(;|$)/i.test(headers['content-type'] ?? ''))
    return {ok: false, status: 415, reason: 'content_type'}; // text/plain needs no preflight
  if (!tokenMatchesSession(secret, session.id, headers['x-csrf-token']))
    return {ok: false, status: 403, reason: 'token'};
  return {ok: true};
}

Check stored state after every browser decision

The initial email is alice@example.test. A valid request with Alice's session and token changes it to next@example.test. A forged form carrying only the cookie must leave the initial email unchanged. Reset state between independent tests so one earlier success cannot obscure a later unauthorized change. Check persistence through a trusted read path, not through whether the attacking page can read the HTTP response.

Cover normal multi-tab use, token rotation and expired sessions. A per-session token commonly supports multiple tabs; if the framework rotates tokens at authentication or privilege changes, obtain the new token through the supported flow. An expired token should fail without bypass, while the interface should let an authenticated user reload and resubmit safely. Log failure categories and request ids rather than token or cookie values. The browser lab at /labs/csrf/ asks you to protect this kind of account change and checks the stored state after each forged request. Frequent failures after a deployment may indicate inconsistent session stores, proxy origin misconfiguration or cached forms carrying obsolete tokens.

Use your framework's protection

Prefer the maintained mechanism your framework ships, and keep it switched on for every cookie-authenticated route. Go 1.25 added http.CrossOriginProtection to the standard library: it rejects unsafe cross-origin browser requests using Sec-Fetch-Site, or Origin against Host in older browsers, and answers 403. It allows requests that carry neither header, treating them as same-origin or non-browser clients, so keep a token where very old browsers matter. Its tests, run with Go 1.27, cover the cases in the code below. For other stacks, the CSRF fix cards at /fix/csrf/ have tested code for Django, Flask, Spring Security, ASP.NET Core and Express.

  • Django: CsrfViewMiddleware is on by default; it checks the token and, over HTTPS, the Origin header against the host and CSRF_TRUSTED_ORIGINS.
  • Spring Security: CSRF protection is on by default for unsafe methods; follow its documented token integration for single-page applications rather than disabling it.
  • ASP.NET Core: antiforgery tokens are automatic in Razor Pages and MVC forms; minimal APIs that bind forms need app.UseAntiforgery().
  • Ruby on Rails: protect_from_forgery is enabled by default in new applications.
  • Laravel: the CSRF middleware in the web middleware group checks the token.
  • Express: there is no built-in protection and the old csurf package is deprecated, so implement the checks above or use a maintained library.
Fixedmain.goGo
package main

import (
	"log"
	"net/http"
)

// newHandler wraps every route in Go 1.25's built-in cross-origin protection.
// Unsafe methods (anything but GET, HEAD and OPTIONS) from another origin get 403,
// judged by Sec-Fetch-Site or, in older browsers, by Origin against Host.
func newHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /account/email", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("email updated")) // after session authentication and validation
	})
	cop := http.NewCrossOriginProtection()
	// Only for an origin that legitimately posts to this app, such as a separate SSO host.
	if err := cop.AddTrustedOrigin("https://login.example.test"); err != nil {
		log.Fatal(err)
	}
	return cop.Handler(mux)
}

func main() {
	log.Fatal(http.ListenAndServe("127.0.0.1:8080", newHandler()))
}

Keep the protection attached to the actual authentication model

CSRF protection addresses credentials the browser attaches without the requesting script explicitly choosing them. A separate API using an Authorization bearer token is a different model and needs token-storage, cross-origin and authorization review. An endpoint that supports both cookies and bearer tokens cannot be exempted wholesale because one caller uses the latter. A same-origin XSS can often read the token or make requests from the trusted page, so CSRF tokens cannot compensate for unsafe rendering.

CORS, Origin validation, SameSite and Fetch Metadata have distinct roles. CORS governs browser response sharing and preflight behavior; it is not the business authorization decision. Fetch Metadata and Origin establish where a request came from, and the OWASP cheat sheet now treats Sec-Fetch-Site as the primary check for applications whose users run current browsers. Token checks establish a session-bound secret the attacker's form should lack, which also covers older browsers. Preserve the framework's complete documented design instead of assembling isolated header checks without considering absent headers, login transitions and actual deployed proxy behavior.

A real-world failure

CVE-2018-7634 was a CSRF flaw in Enalean Tuleap 9.17: the change-email function had no CSRF protection, so a forged cross-site request could switch a victim's registered address and take over the account. The missing control was a CSRF token or an Origin check on the state-changing request.

Define missing-header and mixed-authentication exceptions narrowly

The strict Origin rule in the fixture is intentionally easy to evaluate, but a production framework may support legitimate requests without Origin using a documented Referer-based check or another integrated mechanism. An exception must specify which route, authentication mode and request shape it covers. 'Allow missing Origin' applied globally removes a useful check from callers whose behavior was never examined. A privacy proxy or browser behavior change should lead to investigation of the supported flow, not an unbounded fallback to allow.

Similarly, a machine client using a bearer token should not force all session-authenticated clients onto an exempt path. Separate authentication modes or ensure the exemption applies only when a valid explicit bearer credential is the sole accepted authority for that request. If the server falls back to the cookie when the bearer header is absent or invalid, the supposedly machine-only route still has an ambient-credential path.

Login and account linking deserve explicit review because an attacker may try to bind the victim's browser to an unintended account or identity provider. Treat these as state-changing workflows with their own request binding, rather than assuming CSRF matters only after login. For login, create a pre-session with its own token for the login form, then discard it and issue a new session after authentication, which also prevents session fixation. For a practical extension of the fixture, remove A1 entirely while retaining TA: authentication must fail before any email change. A CSRF token is evidence within a session protocol, not a standalone login credential.

Guided practice

Evaluate a complete email-change policy. All cases start from the same initial email unless explicitly sequenced.

Supplied scenario material
Plain text
Initial Alice email=alice@example.test. Valid Alice session=A1 with token TA. Valid Bob session=B1 with token TB. Expired session=A0.
Origin policy: exact https://portal.example.test required; missing Origin rejected.
A: session A1; Origin trusted; token TA; email next@example.test.
B: session A1; Origin https://other.example.test; no token; email stolen@example.test.
C: session A1; Origin trusted; token TB; email stolen@example.test.
D: session A0; Origin trusted; token TA; email next@example.test.
E: session A1; Origin missing; token TA; email next@example.test.
F: second tab, A1; Origin trusted; token TA; email tab2@example.test.
Assume cookies arrive in every request. Tokens remain valid for the session across tabs.
  1. 1. Identify the ambient authority

    Explain why the vulnerable cookie-only handler accepts B even if its response is unreadable cross-origin.

    Expected result and reasoning

    A1 authenticates Alice, so a cookie-only handler changes her email. Browser response-read restrictions do not roll back a server write. The missing evidence is an intended request from the trusted application context.

  2. 2. Compute repaired status and state

    Apply session, origin and token checks to A–E in order.

    Status of each request after the repair

    A: session A1, trusted Origin, token TA
    B: session A1, Origin other.example.test, no token
    C: session A1, trusted Origin, token TB
    D: expired session A0, trusted Origin, token TA
    E: session A1, no Origin, token TA

    Expected result and reasoning

    A -> 200, next@example.test. B -> 403, initial email. C -> 403, initial email because TB is not TA. D -> 401, initial email. E -> 403, initial email under the stated strict origin policy.

  3. 3. Test an ordinary boundary

    Evaluate F and explain whether token reuse automatically means attack.

    Expected result and reasoning

    F -> 200, tab2@example.test. A session token may legitimately be reused across tabs under this policy. Treating every reuse as replay would break normal forms without being required by the chosen design.

  4. 4. Find the missing route

    A legacy GET /account/email?value=stolen@example.test still mutates using A1. State the correction and regression.

    Expected result and reasoning

    Remove the GET mutation or require the protected unsafe-method flow. A GET must leave alice@example.test unchanged even with a valid session. Securing only the new POST endpoint leaves the legacy bypass intact.

Your deliverable: A six-request status/state table and a legacy-route regression with no missing environment assumptions.

Paper exercise: sessions, tokens, origins and requests are supplied. csrf.mjs and the browser lab exercise the same rules in code.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

CSRF lab: Protect an account change from a forged request

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP CSRF Prevention
  2. Spring Security: CSRF integration
  3. MDN: CORS
  4. Go net/http: CrossOriginProtection. Go 1.27.
  5. MDN: Sec-Fetch-Site
  6. MDN: Set-Cookie, SameSite and cookie prefixes
  7. Django: cross-site request forgery protection. Django 6.1.
  8. ASP.NET Core: prevent cross-site request forgery
  9. NVD: CVE-2018-7634, CSRF to account takeover in Tuleap

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-3.3.2 · v5.0.0-3.3.3 · v5.0.0-3.5.1 · v5.0.0-3.5.2 · v5.0.0-3.5.3
CAPEC attack patterns
OWASP WSTG v4.2 tests
WSTG-SESS-05

Download this guide as Markdown