CSRF
Handlr uses a token-in-header CSRF strategy that works cleanly with both fetch and HTMX, and pairs with a same-origin check. The backend owns the token; the frontend mirrors it from a cookie into a request header.
The strategy
- The backend keeps a CSRF token in the session and mirrors it into a JS-readable cookie named
XSRF-TOKEN(httponly: false,SameSite=Lax,secureon HTTPS), and also returns it in theX-CSRF-Tokenresponse header. - The frontend reads the cookie and sends the token back as the
X-CSRF-Tokenrequest header on every mutating request. - The backend validates the header against the session token (constant-time), then rotates the token after a successful mutation.
Because the token lives in a cookie, all tabs share it, and a page reload doesn't lose it.
Frontend wiring
./init turns this on — you don't call anything. Under the hood (core/csrf.js):
window.fetchis patched to injectX-CSRF-Tokenon/api/requests.- An
htmx:configRequestlistener injects the same header on every HTMX request.
If you make your own requests, use apiFetch (which sets the header, JSON-encodes, and captures a rotated token) or read the token yourself:
import { getCsrfToken } from '@phillipsharring/handlr-frontend';
fetch('/api/thing', { method: 'POST', headers: { 'X-CSRF-Token': getCsrfToken() } });setCsrfToken() exists for API symmetry but is a no-op — the cookie is the source of truth, managed by the backend.
Backend pipes
Three pipes cooperate, wired into the same junctions as the session/auth pipes:
VerifyOriginPipe— for non-safe methods, checks theOrigin/Refererhost matchesHost; a mismatch is a403. Absent headers (a barecurl) are allowed — the token is the primary defense, origin is defense-in-depth.EnsureCsrfTokenPipe— ensures a session token exists, sets theXSRF-TOKENcookie and theX-CSRF-Tokenresponse header.VerifyCsrfTokenPipe— for non-safe methods, validates the incomingX-CSRF-Tokenheader (403on failure), then rotates the token after the request succeeds.
$router->group('/api', [CorsPipe::class, VerifyOriginPipe::class])
->through([StartSessionPipe::class, SessionAuthPipe::class, EnsureCsrfTokenPipe::class])
->through([VerifyCsrfTokenPipe::class, RequireAuthPipe::class])
->junction('api.authed')
->end()
->end()
->end();Safe methods (GET/HEAD) aren't verified — they don't mutate — but EnsureCsrfTokenPipe still hands out a token so the first mutation has one.
Token rotation and 403 recovery
Because the token rotates after each successful mutation, a stale in-flight token can occasionally produce a 403. The frontend handles this: on a 403 the auth-state layer captures the fresh token (already in the rotated cookie/header) and shows a "session expired" warning toast, so the user can retry with the current token rather than hitting a dead end. See Auth State.
See also
- Auth State — 401/403 interception on the client.
- Auth — the session pipes CSRF runs beside.
