Skip to content
wiki edited this page Sep 4, 2026 · 1 revision

CSRF protection

On by default, for routes that need it and no others.

A route is protected when it authenticates through a cookie-based scheme — one implementing CookieScheme — using an unsafe method, and has not opted out. Everything else gets no middleware at all.

A Bearer or API-key route is not CSRF-exposed: the browser does not attach those credentials automatically, so an attacker's page would have to already know the token — and if it does, CSRF is the least of the problems.

The attack

A page on attacker.example causes the victim's browser to send a state-changing request to app.example, and the browser attaches the victim's cookies because that is what cookies are for. The attacker never reads the response and does not need to — the transfer, the password change, the deletion has already happened.

CORS does not prevent this. A "simple" request — a form POST with application/x-www-form-urlencoded, multipart/form-data or text/plain — gets no preflight at all. The browser sends it, the server receives it, and the server commits. CORS then stops the attacker from reading the response, which is no help.

Two checks, both applied

1. Origin check — the strong one

A cross-site request carries an Origin header the browser sets and script cannot forge. Checking it against the application's allowlist rejects the forgery outright.

policy := rextension.OriginPolicy{
	AllowedOrigins:   []string{"https://app.example.com"},
	AllowCredentials: true,
}
security.WithCSRFPolicy(policy)

A non-empty allowlist does not implicitly allow the application's own origin. Browsers send Origin on unsafe same-origin requests too, so an app served from the same origin as the API is checked against this list like any other caller and is rejected unless it appears in it.

That is invisible in a BFF layout, where the SPA is on another port and never presents the API's own origin — and it bites the moment the two are served together.

An empty allowlist disables the origin check and leaves only the token. That is a real weakening, and it is warned about at startup rather than silently accepted.

2. Double-submit token

A random token in a cookie must match the same token in a header or form field. The attacker's page can cause the cookie to be sent but cannot read it, so it cannot produce the matching header.

This covers what the origin check misses: a browser that omits Origin, and same-site-but-cross-origin requests where the origin is technically within the site.

const (
	DefaultCSRFCookieName = "csrf_token"
	DefaultCSRFHeaderName = "X-CSRF-Token"
	DefaultCSRFFormField  = "csrf_token"
)

A handler rendering a form reads the token out of the request:

token := security.CSRFToken(ctx.Request(), csrfCfg)

A JavaScript client reads the cookie and echoes it:

fetch("/api/orders", {
  method: "POST",
  credentials: "include",
  headers: { "X-CSRF-Token": readCookie("csrf_token") },
  body: JSON.stringify(order),
})

The token cookie is not HttpOnly — script has to read it. That is fine: its only job is to be unreadable cross-origin, which the same-origin policy already guarantees.

Why not rely on SameSite=Lax alone

SameSite=Lax does block cross-site POST — the session cookie is not sent, so the request arrives unauthenticated. It is a genuine and large mitigation, and the framework sets it by default. But it is not sufficient:

  • Same-site but cross-origin. evil.app.example.com and app.example.com are the same site, so Lax sends the cookie. Any subdomain takeover, any user-content subdomain, becomes a CSRF vector.
  • State-changing GET. Lax sends cookies on top-level GET navigation by design. A GET that changes state is forgeable from an <img> tag.
  • The historical Lax+POST grace window. Chrome allowed cross-site POST with a Lax cookie for two minutes after it was set, to avoid breaking single sign-on flows. Behaviour that has changed once can change again.
  • Browsers without SameSite. Fewer every year, but "fewer" is not "none", and the ones without it are disproportionately the ones a victim is unlikely to have updated.

Why default-on

Consistent with the fail-closed stance on authorization. A browser-facing cookie session with opt-in CSRF protection is opt-in security — which means it is off in every application whose author did not think about it, and those are exactly the applications that need it.

Opting out

Per route — the right granularity:

type WebhookRoute struct{ rxroute.Route }

func (r *WebhookRoute) CSRFExempt() bool { return true }

Legitimate uses exist: a webhook receiver authenticated by a signature rather than a cookie, an endpoint a non-browser client posts to. Each one is a deliberate statement, which is why it has to be written on the route.

Application-wide:

security.WithoutCSRF()

⚠ Only correct for an application that serves no browser-facing cookie session at all. For a single route, use CSRFExemptRoute.

Configuration

security.WithCSRF(&security.CSRFConfig{
	Policy:       policy,
	CookieName:   "__Host-csrf_token",
	HeaderName:   "X-CSRF-Token",
	FormField:    "csrf_token",
	CookieMaxAge: 12 * time.Hour, // zero → session cookie
	Logger:       logger,
})

The default cookie name is not __Host- prefixed: that prefix requires Secure, no Domain and Path=/, which is correct in production and breaks http://localhost development. Set it if you can meet the requirements — it is strictly stronger, because it prevents a subdomain from writing the cookie.

AllowInsecureTransport permits the token cookie over plain HTTP. Local development only.

Errors

Sentinel Means
ErrCSRFOriginRejected the request's Origin is not in the allowlist
ErrCSRFTokenMissing neither the cookie nor the echoed token was present
ErrCSRFTokenMismatch the cookie and the echoed token differ

The client is told only that the request was rejected. The reason goes to the configured logger.

Where it runs

rextension.PriorityCSRF (450) — after authentication (400), because a double-submit check needs the session the authenticator established, and before validation (500), so a rejected body is never parsed.

Attached via CSRFFactory, which returns nil for every route that is not cookie-authenticated — so those routes carry no CSRF middleware at all.

Clone this wiki locally