Skip to content

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:

typescript
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).
typescript
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:

typescript
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:

  1. The configured request header (default X-CSRF-Token).
  2. 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:

typescript
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:

html
<!-- 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>
html
<!-- 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:

handlebars
<script src='{{assetUrl "csrf-client.js"}}' defer></script>

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:configRequest and sets the configured CSRF header from the cookie. HTMX-driven POSTs work without changes.
  • Plain forms — listens for the document submit event 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:

html
<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:

typescript
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

typescript
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)
});
OptionDefaultDescription
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
cookieSecurefalseSet 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
tokenLength32Length in bytes (tokenLength * 2 hex chars) for session-stored mode. Minimum 16

Advanced: Direct Service Access

Inject CsrfService for custom validation flows:

typescript
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);
  }
}
MethodDescription
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