CSRF Protection
Cross-Site Request Forgery protection is built into the global middleware chain via csrf_mw() (lib/middleware/csrf.ts), which runs after set_locale. Tokens are HMAC-signed by Bun.CSRF, carry their own expiry, and are bound to the requesting browser's identity: the token travels as a csrf_token cookie plus a _csrf_token form field or X-CSRF-Token header, and the middleware verifies the signature rather than comparing two raw values.
Because a token is bound to an identity, a token lifted from one user no longer validates for another. Logged-in requests bind to the sid session cookie; anonymous requests (login, register, invite - exactly where CSRF matters most) bind to a dedicated opaque csrf_sid cookie issued alongside the token. Verification accepts either binding so a session id that rotates on login doesn't invalidate forms rendered moments earlier.
Unlike rate limiting and caching, CSRF protection has no enable flag - it is always on. In production it additionally requires CSRF_SECRET (at least 32 chars, stable across restarts); the server refuses to boot without it. Dev and test fall back to an ephemeral per-process secret so a fresh checkout runs with no setup.
How It Works
- Cookie is issued. On any response that doesn't already have one,
csrf_mw()sets a signedcsrf_tokencookie (24-hour max-age), plus an anonymouscsrf_sidbinding id when the request has no session. - 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 verifies itsBun.CSRFsignature against one of the request's binding identities. A bad signature (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 bypass CSRF checks:
/login/validate/register/validate/invite/validate/profile/validate/password/validate- any path ending in
/validate(catch-all for generated CRUD) - paths starting with
/__(static/API internals)
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.