-
Notifications
You must be signed in to change notification settings - Fork 0
CSRF
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.
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.
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.
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.
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.comandapp.example.comare 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.
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.
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.
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.
| 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.
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.
rextension-security — authentication, authorization and CSRF for Rex · MIT · © 2026 Kryovyx