CSRF
@keryxjs/csrf adds opt-in CSRF protection to a Keryx app. It exposes a GET /csrf-token endpoint that issues a per-session token, and a CsrfMiddleware you attach to any state-changing action you want to protect. Tokens are stored in Redis, keyed by session ID, and remain stable for the configured TTL.
This plugin is the recommended response to issue #447. The framework's default SameSite=Strict cookie blocks form-based CSRF for most apps, but breaks down once you relax SameSite to Lax / None (e.g. for OAuth flows) or accept application/x-www-form-urlencoded payloads. This plugin closes that gap.
Installation
bun add @keryxjs/csrfConfiguration
csrfPlugin is a factory — call it with the middleware that should guard /csrf-token. Without a session check there, anonymous callers can mint tokens against the framework's auto-created anonymous session, which defeats the protection. Pass whatever session middleware your app uses:
// config/plugins.ts
import { csrfPlugin } from "@keryxjs/csrf";
import { SessionMiddleware } from "../middleware/session";
export default {
plugins: [csrfPlugin({ tokenActionMiddleware: [SessionMiddleware] })],
};Other config knobs (all optional):
// config/csrf.ts
export default {
csrf: {
tokenTtl: 3600, // seconds; refreshed on each /csrf-token fetch
paramName: "csrfToken", // params field the token is read from
redisKeyPrefix: "csrf:token", // Redis key prefix
},
};Protecting an Action
Attach CsrfMiddleware to any action that mutates state. Declare csrfToken in the action's input schema — Zod's .object() strips unknown keys before middleware runs, so without this field the token will be silently dropped and every request will fail validation.
import { CsrfMiddleware } from "@keryxjs/csrf";
import { type Action, HTTP_METHOD } from "keryx";
import { z } from "zod";
import { SessionMiddleware } from "../middleware/session";
export class MessageCreate implements Action {
name = "message:create";
middleware = [SessionMiddleware, CsrfMiddleware];
web = { route: "/message", method: HTTP_METHOD.PUT };
inputs = z.object({
body: z.string().min(1),
csrfToken: z.string().optional(),
});
async run(params: { body: string }) {
/* ... */
}
}CsrfMiddleware is opt-in per action — only the actions you list it on are checked. There is no global enforcement and no method-based exemption (don't add it to GET actions).
Fetching a Token (SPA)
Call GET /api/csrf-token once on app bootstrap (or whenever you suspect the session has rotated). The token must be sent as a body field (or query string parameter) on state-changing requests — there is no header support.
Cast the response through ActionResponse<CsrfTokenAction> so the token field is typed at the call site (see Typed Clients for the full pattern):
import type { ActionResponse } from "keryx";
import type { CsrfTokenAction } from "@keryxjs/csrf";
async function getCsrfToken(): Promise<string> {
const res = await fetch("/api/csrf-token", { credentials: "include" });
const body = (await res.json()) as ActionResponse<CsrfTokenAction>;
return body.token;
}
const csrfToken = await getCsrfToken();
await fetch("/api/message", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: "hello", csrfToken }),
});The same token is returned on subsequent calls until it expires or the session is regenerated.
Sending the Token from an HTML Form
<form method="POST" action="/api/login">
<input type="hidden" name="csrfToken" value="{{csrfToken}}" />
<input name="email" />
<input name="password" type="password" />
<button type="submit">Sign in</button>
</form>The middleware reads the token from the action's params (folded together from JSON body, form-encoded body, and query string). If you prefer a query parameter for GET-style state changes, append ?csrfToken=... to the URL.
What's Validated
CsrfMiddleware.runBefore:
- Skips non-web transports (
task,cli,mcp,websocket) — those have no cross-site request concept. - Requires that a session has been established. Anonymous callers get
403. - Reads the candidate token from
params[config.csrf.paramName](defaultcsrfToken). - Compares it constant-time against the value stored at
csrf:token:${sessionId}in Redis. - Throws
403on miss / mismatch / expiry.
Token Lifecycle
- Issuance —
GET /csrf-tokenlooks up the token currently bound to the caller's session. If none exists, a fresh 32-byte URL-safe random token is generated. In both cases, the TTL is refreshed. - Stability — the same token is returned for the same session until it expires. This keeps long-lived tabs and cached forms working.
- Rotation — call
connection.regenerateSession()(e.g. on login) to invalidate the previous token. The new session has no token until the next/csrf-tokenfetch.
Limitations
- Tokens are bound to the session, so the caller must have a session before fetching one. The framework auto-creates an anonymous session on first request.
- This plugin does not validate the
Originheader. If you want that as a defense-in-depth measure, layer it in front of the middleware (or open an issue for a follow-up).