CSRF Tokens
The idea
A CSRF token is a random secret that the server generates and the client includes in every state-changing request. The attacker cannot know the token because they cannot read your app’s pages (the same-origin policy prevents it).
The server checks: does this request include a valid CSRF token? If not, reject it.
The double-submit cookie pattern
There are several ways to implement CSRF tokens. We will use the double-submit cookie pattern because it works well with stateless APIs and does not require server-side token storage.
Here is how it works:
- The server sends a random token in a cookie (a separate cookie from the session cookie)
- The client reads the token from the cookie and includes it in a request header
- The server checks that the header value matches the cookie value
Why does this work? The attacker can trigger a request that includes the CSRF cookie (browsers attach cookies automatically). But the attacker cannot read the cookie from their page (same-origin policy), so they cannot set the matching header. The request arrives with the cookie but without the header, and the server rejects it.
Generate the CSRF token
Create src/csrf.ts:
// src/csrf.ts
const CSRF_COOKIE = "csrf_token";
const CSRF_HEADER = "x-csrf-token";
export function generateCsrfToken(): string {
return crypto.randomUUID();
}
export function csrfCookie(token: string): string {
// NOT HttpOnly — JavaScript needs to read this cookie to set the header
return `${CSRF_COOKIE}=${token}; SameSite=Lax; Path=/; Max-Age=86400`;
}
export function verifyCsrf(request: Request): boolean {
// Read the token from the cookie
const cookieHeader = request.headers.get("cookie");
const cookies = parseCookiesFromHeader(cookieHeader);
const cookieToken = cookies[CSRF_COOKIE];
// Read the token from the request header
const headerToken = request.headers.get(CSRF_HEADER);
// Both must exist and match
if (!cookieToken || !headerToken) return false;
return cookieToken === headerToken;
}
function parseCookiesFromHeader(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [name, ...rest] = pair.trim().split("=");
if (name) cookies[name] = rest.join("=");
}
return cookies;
} Notice the CSRF cookie is not HttpOnly. This is intentional. The client JavaScript needs to read the cookie value to set the X-CSRF-Token header. The session cookie remains HttpOnly because JavaScript never needs to read it.
Set the token on every response
Use the onResponse hook to include the CSRF cookie in every response:
// In src/app.ts
import { generateCsrfToken, csrfCookie } from "./csrf.js";
onResponse: ({ request, response, locals }) => {
const headers = new Headers(response.headers);
// Set CSRF token if not already present
const existingCookie = request.headers.get("cookie") ?? "";
if (!existingCookie.includes("csrf_token=")) {
headers.append("set-cookie", csrfCookie(generateCsrfToken()));
}
// ... other response modifications (logging, etc.)
return new Response(response.body, {
status: response.status,
headers,
});
}, The token is set once and persists via the cookie. If the cookie is already present (returning user), we skip generating a new one.
Verify on state-changing requests
Add CSRF verification to routes that change state. You can do this per-handler or create a helper:
// src/csrf.ts — add this
export function requireCsrf(request: Request): true | Response {
if (!verifyCsrf(request)) {
return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
}
return true;
} Use it in handlers:
route.post("/users", {
resolve: async (c) => {
const csrf = requireCsrf(c.request);
if (csrf instanceof Response) return csrf;
const caller = authenticatedAdmin(c.request);
if (caller instanceof Response) return caller;
// ... create user
},
}); Same pattern as authenticate and requireAdmin: call the function, check with instanceof Response.
What the client does
The client JavaScript reads the CSRF cookie and includes it as a header:
// Client-side fetch example
function getCookie(name) {
const match = document.cookie.match(new RegExp(`${name}=([^;]+)`));
return match ? match[1] : null;
}
const response = await fetch("/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCookie("csrf_token"),
},
body: JSON.stringify({ name: "Alice", email: "[email protected]" }),
}); This is the client’s only responsibility: read the cookie, set the header.
When to require CSRF tokens
Require CSRF verification on:
- POST, PUT, PATCH, DELETE routes (any state-changing method)
- Routes that use cookie-based authentication (sessions)
Do not require CSRF verification on:
- GET, HEAD, OPTIONS routes (these should not change state)
- Routes that use token-based auth (Authorization header). Token-based auth is inherently CSRF-safe because the browser does not attach the token automatically.
Exercises
Exercise 1: Add CSRF token generation and verification to your app. Make a POST request without the X-CSRF-Token header and verify you get 403. Then include the header with the correct value and verify it succeeds.
Exercise 2: Try setting the X-CSRF-Token header to a random value that does not match the cookie. Verify you get 403. The header and cookie must match.
Exercise 3: Why is the CSRF cookie not HttpOnly? If it were, client JavaScript could not read it to set the header, and the double-submit pattern would not work. The cookie does not need to be secret from JavaScript on the same page — it needs to be secret from JavaScript on other pages (which the same-origin policy ensures).
Why does the double-submit cookie pattern work against CSRF?
Why is token-based auth (Authorization header) inherently CSRF-safe?