CSRF for API Consumers
The problem with CSRF tokens and APIs
The double-submit cookie pattern works well when your frontend is a browser-based app that can read cookies and set headers. But what about other clients?
A mobile app does not have document.cookie. A third-party integration making server-to-server calls does not use cookies at all. Requiring a CSRF cookie-and-header pair from these clients adds friction for no security benefit — CSRF is a browser-specific attack.
Custom header verification
A simpler approach for API endpoints: require a custom header on every state-changing request. Any custom header will do:
// src/csrf.ts — add this alternative
export function requireCustomHeader(request: Request): true | Response {
const header = request.headers.get("x-requested-with");
if (!header) {
return Response.json({ error: "Missing required header" }, { status: 403 });
}
return true;
} Why does this work? A cross-site form submission (the classic CSRF vector) cannot set custom headers. HTML forms can only set the Content-Type header (and only to a few values). An attacker’s <form action="..." method="POST"> cannot include X-Requested-With.
A cross-site fetch request can set custom headers, but it triggers a CORS preflight (an OPTIONS request). If your server does not respond to the preflight with the right CORS headers allowing the attacker’s origin, the browser blocks the request.
So requiring any custom header is enough to block CSRF from cross-site forms, and CORS blocks cross-site fetch requests with custom headers.
When to use which approach
Double-submit cookie (previous lesson): Use when your browser frontend uses cookie-based sessions and you want explicit CSRF protection on top of SameSite=Lax. This is the belt-and-suspenders approach.
Custom header verification (this lesson): Use when your API serves a mix of browser and non-browser clients. The browser clients include the header from their JavaScript. Non-browser clients include it naturally (any HTTP client can set custom headers). No cookies involved.
Neither (token-based auth only): If all your clients use the Authorization: Bearer header and none use cookies, CSRF is not a concern. The browser does not attach the Authorization header automatically.
Choosing your strategy
For the app we are building (session-based auth for browsers, token-based auth for APIs), a practical approach is:
function csrfProtection(request: Request): true | Response {
// Token-based auth: no CSRF needed
if (request.headers.has("authorization")) {
return true;
}
// Cookie-based auth: require CSRF protection
return requireCsrf(request);
} If the request includes an Authorization header, it is using token-based auth and CSRF protection is unnecessary. If it uses cookies (no Authorization header), require the CSRF token.
This lets both client types work without compromising security.
Use it in handlers
route.post("/users", {
resolve: async (c) => {
const csrf = csrfProtection(c.request);
if (csrf instanceof Response) return csrf;
// Now do the auth check (session or token)
// ...
},
}); Exercises
Exercise 1: Make a POST request with curl that includes X-Requested-With: XMLHttpRequest and verify it passes the custom header check. Then make the same request without the header and verify it fails with 403.
Exercise 2: Implement the combined csrfProtection function. Test it with a cookie-based session (should require CSRF token) and with a Bearer token (should pass without CSRF token).
Why can't an HTML form set custom headers like X-Requested-With?