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

  1. Cookie is issued. On any response that doesn't already have one, csrf_mw() sets a signed csrf_token cookie (24-hour max-age), plus an anonymous csrf_sid binding id when the request has no session.
  2. Token reaches the template. The middleware also writes the token to the X-CSRF-Token request header. render() reads that header and exposes it to templates as csrf_token.
  3. Form submits it. Every form includes a hidden field carrying the token:
    <input type="hidden" name="_csrf_token" value="{= csrf_token }" />
    
  4. Middleware validates. On POST, PUT, PATCH, and DELETE, the middleware extracts the submitted token and verifies its Bun.CSRF signature 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:

  1. The X-CSRF-Token header - used by AJAX / fetch requests.
  2. A form field named _csrf_token - for application/x-www-form-urlencoded and multipart/form-data submissions.
  3. A _csrf_token JSON property - for application/json request 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.