CSRF
@modularityjs/http-csrf provides session-backed CSRF protection for state-mutating HTTP routes. It supports two modes — session-stored (default) keeps a random token in the session, and double-submit derives the token as an HMAC of the session ID and delivers it via a non-HttpOnly cookie so HTML responses stay shareable downstream. Both modes use timingSafeEqual for comparison.
Setup
HttpCsrfModule depends on SessionModule + HttpSessionModule, and (for the client-side helper script) AssetsModule:
import { HttpModule } from '@modularityjs/http';
import { SessionModule } from '@modularityjs/session';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { SessionMemoryModule } from '@modularityjs/session-memory';
import { HttpSessionModule } from '@modularityjs/http-session';
import { AssetsModule } from '@modularityjs/assets';
import { HttpAssetsModule } from '@modularityjs/http-assets';
import { HttpCsrfModule } from '@modularityjs/http-csrf';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
SessionModule,
SessionMemoryModule,
HttpSessionModule,
AssetsModule,
HttpAssetsModule, // serves the CSRF client script (and any other static asset)
HttpCsrfModule, // default: session-stored mode
];HttpCsrfModule registers a CsrfClientAsset in StaticAssetsPool at boot. HttpAssetsModule serves it at /static/csrf-client.js (the /static prefix is what HttpAssetsModule serves — AssetsConfig.urlPrefix must remain /static for this driver). AssetsModule is imported transitively via HttpCsrfModule, so listing it explicitly is optional.
On every request HttpCsrfModule ensures a CSRF token is available — either by minting one into the session (session-stored mode) or by computing the HMAC and emitting a csrf-token cookie when the request doesn't already present a matching value (double-submit mode).
Modes
Session-stored (default)
A random token (default 32 bytes / 64 hex chars) is generated on the first request and stored under session.data.csrfToken. The token is exposed to templates via getCsrfToken(request) and validated against the request's X-CSRF-Token header or _csrf body field.
Use this mode when:
- Your pages are session-scoped anyway (auth-walled dashboards, mutable user state).
- You don't need to share-cache HTML at a CDN.
- You want the simplest setup with no client-side script.
Double-submit
The token is HMAC-SHA256(secret, session.id) — derived per request, not stored. It travels in a non-HttpOnly csrf-token cookie, and the server validates incoming requests against the recomputed HMAC. The cookie is only emitted when the incoming request doesn't already present the matching value, so steady-state responses carry no Set-Cookie header (CDN-friendly).
Use this mode when:
- Pages should be share-cacheable across users (CDN, reverse proxy, browser cache).
- You're comfortable shipping the client-side helper script (see Client script).
HttpCsrfModule.forRoot({
mode: 'double-submit',
secret: process.env.CSRF_SECRET, // required, ≥ 32 bytes
});The HMAC binding to session.id mitigates the classic double-submit cookie-injection risk: an attacker on a sibling subdomain who can plant a cookie on your domain can't compute the right value because they don't have the secret. The _csrf body field is therefore safe to accept in this mode (and it is — see below).
Protecting Routes
Apply @CsrfProtect() to any state-mutating method or controller class:
import { Body, Controller, Post } from '@modularityjs/http';
import { CsrfProtect } from '@modularityjs/http-csrf';
@Controller('/comments')
class CommentController {
@Post('/')
@CsrfProtect()
create(@Body('text') text: string) {
return this.repo.add(text);
}
}@CsrfProtect() is sugar for @UseGuard(CsrfGuard). The guard skips safe methods (GET, HEAD, OPTIONS) and validates non-safe requests by checking, in order:
- The configured request header (default
X-CSRF-Token). - The configured body field (default
_csrf) — works for plain HTML form submissions where headers can't be set.
If neither matches, the request is rejected with 403.
Body-field validation
The _csrf body field is safe to accept only in double-submit mode, where the token is an HMAC bound to the session id and an attacker cannot forge it without the secret. In session-stored mode, restrict CSRF token transport to the header (X-CSRF-Token) — accepting a body field opens the door to login-CSRF and cross-form token leakage. Set bodyField to a value the guard will never find (or refuse body-bearing forms entirely) when staying on session-stored mode.
Providing the Token to the Client
Session-stored mode
Read the token from the session in a controller and embed it in the HTML response:
import { Get, Html, Request } from '@modularityjs/http';
import { getCsrfToken } from '@modularityjs/http-csrf';
import type { HttpRequest } from '@modularityjs/http';
@Html()
@Controller('/form')
class FormController {
@Get('/')
page(@Request() req: HttpRequest) {
const csrfToken = getCsrfToken(req);
return this.templates.render('form', { csrfToken });
}
}Then include it in your HTML form or HTMX hx-headers:
<!-- Standard form: hidden input -->
<form method="POST" action="/comments">
<input type="hidden" name="_csrf" value="{{ csrfToken }}" />
<input type="text" name="text" />
<button type="submit">Add</button>
</form><!-- HTMX: send as a header on every request from this element -->
<body hx-headers='{"X-CSRF-Token": "{{ csrfToken }}"}'>
...
</body>Double-submit mode — Client script
In double-submit mode the token isn't templated into HTML — the server-rendered HTML stays user-agnostic. Instead, ship the framework's client script and let it read the cookie at request time:
The helper comes from template-assets; add it to the module list. When a manifest is present it resolves to a content-hashed URL like /static/csrf-client-a1b2c3d4.js; otherwise it falls back to /static/csrf-client.js and the http-assets extension serves it.
The script wires two paths:
- HTMX — listens for
htmx:configRequestand sets the configured CSRF header from the cookie. HTMX-driven POSTs work without changes. - Plain forms — listens for the document
submitevent in capture phase and rewrites every<input name="_csrf">with the cookie value before the form leaves the page. Native form submission carries the body field; the server accepts it.
Forms still need an empty placeholder so the script has something to populate:
<form method="POST" action="/comments">
<input type="hidden" name="_csrf" value="" />
<input type="text" name="text" />
<button type="submit">Add</button>
</form>DI-Aware Token Reader
For services that run after the DI container is ready, inject CsrfTokenReader instead of using the free function:
import { Inject, Injectable } from '@modularityjs/di';
import { CsrfTokenReader } from '@modularityjs/http-csrf';
import type { HttpRequest } from '@modularityjs/http';
@Injectable()
class MyService {
constructor(
@Inject(CsrfTokenReader) private readonly csrf: CsrfTokenReader,
) {}
getToken(request: HttpRequest): string | undefined {
return this.csrf.read(request);
}
}CsrfTokenReader.read() respects the configured sessionKey, whereas getCsrfToken() uses the default key (csrfToken) unless a key is passed explicitly. Both return undefined in double-submit mode (the token isn't kept in the session).
Configuration
HttpCsrfModule.forRoot({
// Mode + double-submit fields
mode: 'session-stored', // or 'double-submit'
secret: '', // default empty; ≥ 32 bytes required when mode === 'double-submit' (boot fails on empty)
cookieName: 'csrf-token', // double-submit cookie name
cookiePath: '/', // double-submit cookie path
cookieSecure: false, // set true behind HTTPS
cookieSameSite: 'lax', // 'strict' | 'lax' | 'none'
// Validation knobs (apply to both modes)
headerName: 'X-CSRF-Token', // request header the guard reads
bodyField: '_csrf', // request body field the guard falls back to
sessionKey: 'csrfToken', // session.data key (session-stored mode)
safeMethods: ['GET', 'HEAD', 'OPTIONS'], // skipped by the guard
tokenLength: 32, // bytes of randomness (session-stored mode)
});| Option | Default | Description |
|---|---|---|
mode | 'session-stored' | 'session-stored' keeps a random token in the session; 'double-submit' uses HMAC + cookie |
secret | '' | HMAC key for double-submit mode. Must be ≥ 32 bytes. Boot fails if missing in double-submit mode |
cookieName | 'csrf-token' | Cookie name for double-submit mode. With the __Host- prefix, cookiePath must be / and cookieSecure must be true |
cookiePath | '/' | Cookie path |
cookieSecure | false | Set to true behind HTTPS. Required when using the __Host- cookie prefix |
cookieSameSite | 'lax' | Cookie SameSite attribute |
headerName | 'X-CSRF-Token' | Header the guard checks on non-safe requests |
bodyField | '_csrf' | Body field the guard falls back to when the header is absent |
sessionKey | 'csrfToken' | Key under session.data where the token is kept (session-stored mode) |
safeMethods | ['GET', 'HEAD', 'OPTIONS'] | Methods that bypass validation |
tokenLength | 32 | Length in bytes (tokenLength * 2 hex chars) for session-stored mode. Minimum 16 |
Advanced: Direct Service Access
Inject CsrfService for custom validation flows:
import { Inject, Injectable } from '@modularityjs/di';
import { CsrfService } from '@modularityjs/http-csrf';
import type { SessionData } from '@modularityjs/session';
@Injectable()
class ApiController {
constructor(@Inject(CsrfService) private readonly csrf: CsrfService) {}
validateManually(session: SessionData, candidate: string): boolean {
return this.csrf.validate(session, candidate);
}
}| Method | Description |
|---|---|
generate() | Generate a new random token string (session-stored mode only) |
ensure(session) | Return existing token (session-stored) or compute the HMAC (double-submit) — never returns undefined |
read(session) | Read or compute the current token without storing |
validate(session, candidate) | Constant-time comparison of expected vs provided token |