CSRF Protection
Introduction
Cross-Site Request Forgery protection is built into the global middleware chain via csrf_mw() (lib/middleware/csrf.ts), which runs after set_lang. It uses the double-submit cookie pattern: a random token is stored in a cookie and echoed back in every form, and the middleware rejects any state-changing request where the two don't match. Because an attacker's cross-origin page can't read your cookie, it can't forge the matching field.
Unlike rate limiting and caching, CSRF protection has no enable flag - it is always on. Generated forms already include the token, so for typical CRUD apps it works without any setup.
How It Works
- Cookie is issued. On any response that doesn't already have one,
csrf_mw()sets a randomcsrf_tokencookie (24-hour max-age). - Token reaches the template. The middleware also writes the token to the
X-CSRF-Tokenrequest header.render()reads that header and exposes it to templates ascsrf_token. - Form submits it. Every form includes a hidden field carrying the token:
<input type="hidden" name="_csrf_token" value="{= csrf_token }" /> - Middleware validates. On
POST,PUT,PATCH, andDELETE, the middleware extracts the submitted token and compares it to the cookie. A mismatch (or missing token) is rejected before the handler runs.
GET, HEAD, and OPTIONS are never validated (they shouldn't mutate state), and a small set of read-only validation endpoints is explicitly skipped (see below).
Where the Token Can Come From
extract_csrf_token() looks in three places, in order:
- The
X-CSRF-Tokenheader - used by AJAX /fetchrequests. - A form field named
_csrf_token- forapplication/x-www-form-urlencodedandmultipart/form-datasubmissions. - A
_csrf_tokenJSON property - forapplication/jsonrequest bodies.
For AJAX calls, read the token from the cookie and send it as a header - exactly what generated list pages do for inline delete/bulk actions:
const csrf_token = document.cookie.match(/csrf_token=([^;]+)/)?.[1] || "";
await fetch(url, {
method: "POST",
headers: { "X-CSRF-Token": csrf_token },
body,
});
Exempt Endpoints
Some endpoints are POST but only read and validate input (live form validation), never mutating state. These are listed in SKIP_VALIDATION_PATHS and bypass CSRF checks:
/login/validate/register/validate/invite/validate/profile/validate/password/validate
This mirrors the rate limiter's treatment of */validate endpoints. If you add your own validation-only POST endpoint, add it to this set so the CSRF check doesn't block legitimate keystroke-by-keystroke validation.
Adding the Token to Hand-Written Forms
If you write a form by hand (rather than generating it), include the hidden field so the submission passes validation:
<form method="post" action="/your-route">
<input type="hidden" name="_csrf_token" value="{= csrf_token }" />
<!-- your fields -->
</form>
csrf_token is available in every template render() produces, because the middleware sets the header on every request. If you ever see a CSRF rejection on a hand-written form, the missing hidden field is almost always the cause.