---
key: "csrf-origin-defense"
title: "Protect cookie-authenticated actions against CSRF"
url: "https://softwaresecurity.com/domains/appsec/browser-security/csrf-origin-defense/"
api: "https://softwaresecurity.com/api/v1/articles/csrf-origin-defense.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-352","CWE-1275","A01:2025","V3.3.2","V3.3.3","V3.5.1","V3.5.2","V3.5.3","CAPEC-62","WSTG-SESS-05"]
tags: ["Browser security","Cross-site request forgery (CSRF)"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Protect cookie-authenticated actions against CSRF

Make the server distinguish an intended action from a request another site caused the browser to send.

Canonical URL: https://softwaresecurity.com/domains/appsec/browser-security/csrf-origin-defense/

## 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.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Session cookie | `__Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax` | Use `SameSite=Strict` where users rarely arrive from links on other sites. | OWASP CSRF Prevention Cheat Sheet; OWASP ASVS v5.0.0 V3.3.2 and V3.3.3 |
| Fetch Metadata | Allow unsafe methods only with `Sec-Fetch-Site: same-origin` or `none` | Fall back to an exact `Origin` match when the header is absent. | OWASP CSRF Prevention Cheat Sheet |
| Signed double-submit token | `hex(HMAC-SHA256(secret, len(sid)!sid!len(rand)!rand)).rand` with a fresh random value | Validate the header or form value against the session; never only compare it with the cookie. | OWASP CSRF Prevention Cheat Sheet |
| JSON endpoints | Require `Content-Type: application/json`; reject the three simple content types | — | Fetch Standard: CORS-safelisted request headers |
| Go | `http.NewCrossOriginProtection().Handler(mux)` | Go 1.25 or later; requests with no `Sec-Fetch-Site` and no `Origin` are allowed as non-browser traffic. | Go net/http documentation |

## 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.

## 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.

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

Before you start:

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

Time: About 27 minutes: 8 to read and 19 for the exercise

## 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.

```javascript
// 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.

```go
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 material

```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. Identify the ambient authority

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

Worked answer: `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. Compute repaired status and state

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

Worked answer: A -&gt; 200, `next@example.test`. B -&gt; 403, initial email. C -&gt; 403, initial email because `TB` is not `TA`. D -&gt; 401, initial email. E -&gt; 403, initial email under the stated strict origin policy.

### 3. Test an ordinary boundary

Evaluate F and explain whether token reuse automatically means attack.

Worked answer: F -&gt; 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. Find the missing route

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

Worked answer: 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.

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

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

## Executable practice

- [CSRF lab: Protect an account change from a forged request](https://softwaresecurity.com/labs/csrf/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Spring Security: CSRF integration](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html)
- [MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)
- [Go net/http: CrossOriginProtection](https://pkg.go.dev/net/http#CrossOriginProtection). Go 1.27.
- [MDN: Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site)
- [MDN: Set-Cookie, SameSite and cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
- [Django: cross-site request forgery protection](https://docs.djangoproject.com/en/6.1/ref/csrf/). Django 6.1.
- [ASP.NET Core: prevent cross-site request forgery](https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery)
- [NVD: CVE-2018-7634, CSRF to account takeover in Tuleap](https://nvd.nist.gov/vuln/detail/cve-2018-7634)