4.3 KiB
CSRF — Single-Read Helper Contract
This document covers the runtime API contract of validateCSRFToken in
apps/web/app/utils/session.server.ts. The complementary route-level
inventory of which surfaces are protected lives in
docs/security/csrf-inventory.md — read that file when adding a new
mutating route.
Background — the body-already-consumed bug
Before the May 2026 fix, validateCSRFToken(request) always tried to
locate the request token by either reading the x-csrf-token header or,
falling back, by cloning the request and re-parsing the body:
// historical implementation (broken when caller already drained the body)
const formData = await request.clone().formData();
Cloning a request whose body was already consumed throws
TypeError: unusable in undici. UI form-submits where the action handler
read await request.formData() first and validated afterwards crashed
in production with a 500. The bug was first surfaced during the B-009
lighthouse-bot session-cookie capture, where the workaround was to send
the token via the x-csrf-token header to bypass the body path.
API contract
export type ValidateCSRFTokenOptions = {
/** Already-parsed form data. When set, the helper NEVER reads the body. */
formData?: FormData;
/** Skip body fallback entirely. Header must carry the token. */
headerOnly?: boolean;
};
export async function validateCSRFToken(
request: Request,
options?: ValidateCSRFTokenOptions,
): Promise<void>;
Resolution order
x-csrf-token/csrf-tokenrequest header.- If
headerOnlyistrue, stop here. options.formData?.get("csrf")— used when the caller has already parsed the body.- Body fallback: clone-and-parse the request as
FormData,application/json, orURLSearchParams. Reaching this branch on a request whose body has already been consumed surfaces a CSRF-specific error (CSRF body already consumed; pass { formData } to validateCSRFToken.) instead of the opaqueTypeError: unusablefrom undici.
Calling patterns
// Preferred: validate before reading the body. Header-first short-circuit
// keeps this path zero-cost on header-bearing requests.
export async function action({ request }: ActionFunctionArgs) {
await validateCSRFToken(request);
const form = await request.formData();
// ...
}
// Required when the caller parses the body first (e.g. routing based on a
// form action discriminator). Inject the parsed FormData — never let the
// helper attempt to re-read the consumed body.
export async function action({ request }: ActionFunctionArgs) {
const form = await request.clone().formData();
await validateCSRFToken(request, { formData: form });
// ...
}
// Helpers / cross-file flows that receive a request plus pre-parsed
// FormData from the route action must forward the FormData.
async function loginFromRequest(request: Request, form: FormData) {
await validateCSRFToken(request, { formData: form });
// ...
}
// Bot / lighthouse / capture contexts where the body is reserved for a
// different purpose. The token must be sent in the x-csrf-token header.
await validateCSRFToken(request, { headerOnly: true });
Migrated callsites (May 2026 fix)
apps/web/app/root.tsx— the locale/theme action pre-parses form data for routing and now hands the FormData tovalidateCSRFToken.apps/web/app/modules/users/services/AuthService.ts—loginFromRequestreceivesFormDatafromroutes/login.tsxandroot.tsx, and forwards it.
Any new helper that accepts (request, formData) from a caller MUST
forward the FormData to validateCSRFToken. The convention check is
covered by apps/web/app/utils/session.csrf-regression.test.ts.
Verification
pnpm exec vitest run \
apps/web/app/utils/session.server.test.ts \
apps/web/app/utils/session.csrf-regression.test.ts \
apps/web/app/utils/session-csrf-sweep.test.ts
The regression file specifically asserts:
- Calling the helper after
await request.formData()succeeds when the parsed body is injected. - Calling the helper after the body was consumed without injection raises
a CSRF-specific error — never the undici
TypeError: unusable. - The header-token fast path still skips the body even when the body has already been read.