Security hardening
There's no single "secure mode" switch; HTTP hardening is a stack of independently-wired modules. The framework's job is to make composition predictable — every Fastify-side plugin registers in afterLoad so their onSend hooks run before route handlers, and route-level decorators (@RequireAuth, @RequireMfa, @CsrfProtect) apply additively. This page is the production checklist for the HTTP surface.
Layer order
Process incoming requests from outside in. Each layer below depends on the ones above behaving correctly:
- Security headers — set on every response, regardless of route or status.
- CORS — bounce disallowed origins before route handlers run.
- Rate limit — drop floods before they reach business logic.
- CSRF — verify state-changing requests aren't cross-origin forgeries.
- Authentication — resolve a caller identity, or reject.
- MFA — require a second factor on sensitive endpoints.
- Authorization — gate the actual action against the identity's permissions.
Security headers
@modularityjs/http-fastify-security-headers registers Helmet via fastify.register(helmet, options) in afterLoad. The defaults are strict; turn things off only with cause.
HttpFastifySecurityHeadersModule.forRoot({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: { maxAge: 60 * 60 * 24 * 365, includeSubDomains: true, preload: true },
// frameguard, referrerPolicy, noSniff, COOP/CORP/COEP all default-on
}),If you serve a docs UI on /docs (Swagger UI / Scalar / etc.), it usually needs scriptSrc: ["'self'", "'unsafe-inline'"] and styleSrc: ["'self'", "'unsafe-inline'"] — work that exception in deliberately rather than disabling CSP wholesale.
CORS
@modularityjs/http-fastify-cors is closed by default — origin: false rejects every cross-origin request. Open it explicitly:
HttpFastifyCorsModule.forRoot({
origin: ['https://app.example.com', 'https://admin.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}),credentials: true is required if the browser will send cookies / Authorization headers, but it forbids origin: '*' — the browser rejects the combination. Always allow-list explicit origins for credentialed requests.
Rate limit
@modularityjs/http-rate-limit attaches an onRequest handler that calls RateLimiterService.consume(key). Production needs @modularityjs/rate-limit-redis so the counter is shared across replicas — rate-limit-memory counts per-pod, which an attacker can bypass by spreading load.
RateLimitModule,
RateLimitRedisModule.forRoot({ limit: 100, windowMs: 60_000 }),
HttpRateLimitModule.forRoot({
keyExtractor: (req) => {
// Prefer the authenticated identity; fall back to forwarded-for.
const auth = (req as { auth?: { id?: string } }).auth;
const forwarded = req.headers['x-forwarded-for'];
const ip = Array.isArray(forwarded) ? forwarded[0] : forwarded;
return auth?.id ?? ip ?? 'unknown';
},
}),The default key extractor reads x-forwarded-for. If your load balancer doesn't strip / set it correctly, every request looks like it comes from the LB and the bucket is shared — set the LB to send a trusted client-IP header and override the extractor to read it.
See Rate Limit.
CSRF
@modularityjs/http-csrf ships two modes — pick once per app, not per route:
| Mode | Token shape | Cacheable HTML? | When |
|---|---|---|---|
session-stored | Random, kept in session.data | No | Default. Dynamic per-user HTML, server-rendered forms. |
double-submit | HMAC-SHA256(secret, sessionId) | Yes | Pages cached at the CDN; thin server + SPA. |
HttpCsrfModule.forRoot({
mode: 'double-submit',
secret: process.env.CSRF_SECRET!, // ≥ 32 bytes
headerName: 'X-CSRF-Token',
cookieSecure: true,
cookieSameSite: 'lax',
}),@CsrfProtect() decorates the routes that mutate state. Body-field validation (_csrf) is only safe in double-submit mode — in session-stored mode the plaintext token is readable in the rendered HTML, so a network observer with the same session can forge it; the HMAC binding in double-submit mode is what makes the body-field path safe.
See HTTP CSRF.
Authentication
AuthService walks AuthResolversPool in registration order; the first resolver to return an AuthIdentity wins. Multiple HttpAuthenticators can coexist — JWT for the API, session cookie for the dashboard, API key for partners. The pool is FIFO, so put your hot-path resolver (e.g. JWT) first.
AuthModule,
AuthJwtModule.forRoot({
secret: process.env.JWT_SECRET!, // ≥ 32 bytes, HMAC key
issuer: 'https://auth.example.com',
audience: 'orders-api',
}),
HttpAuthModule,
HttpAuthJwtModule, // looks at Authorization: Bearer
HttpAuthSessionModule, // reads the session cookie and the identity it carriesRoutes opt in with @RequireAuth() (class or method). When set, the HTTP extension returns 401 if no authenticator matched. Access the identity in a handler with @Auth() identity: AuthIdentity or @Auth('id') userId: string.
For OIDC, prefer auth-oidc-introspection over auth-oidc when the upstream IdP supports it — introspection checks revocation, JWT alone doesn't. See Auth.
MFA
@modularityjs/mfa keeps factors (MfaFactor) in a pool keyed by type (totp, webauthn, email, sms, backup-codes). The HTTP extension @modularityjs/http-mfa adds @RequireMfa() which checks request.auth.mfa === true. The framework doesn't decide how you flip that flag — apps mint it onto the session or JWT after a successful MfaService.verify(...). Pattern:
- User authenticates → session is created with
mfa: false. - App routes return 403 for
@RequireMfa()routes; client redirects to MFA challenge. - App calls
MfaService.challenge(identity, 'totp')→ returns aMfaChallengePayloadwithchallengeIdand factor-specific hints. - User submits response → app calls
MfaService.verify(challengeId, response). - On success, the app stores an MFA-verified flag on the session (e.g.
session.data.mfa = true) and ideally rotates the session id viasessionService.regenerate(session.id). The auth resolver that handles this session must read the flag back ontorequest.auth.mfafor subsequent requests —MfaGuardonly looks atrequest.auth.mfa, not at session storage. - Now
@RequireMfa()routes pass.
Challenges are stored in MfaChallengeStore (cache-backed), which needs cache-redis for multi-replica. Per-identity secrets (TOTP base32, WebAuthn credential JSON) go in MfaSecretStore — that's app-owned, since secrets often live alongside user records.
See MFA.
Authorization
@modularityjs/authz adds a layer past auth — "this identity can do this action on this resource". Two strategies:
authz-rbac— role → permission mapping. Simple, no per-request data fetch.authz-policy—Policyclasses evaluated per resource. Fits row-level / ownership-based rules.
Pick one per app; mixing both in one resource model creates two sources of truth. See Authz.
Fastify plugin ordering
This isn't optional, it's a framework rule that protects against subtle bugs:
Bridges that register Fastify plugins must do so in
afterLoad, notonInit.
The HttpFastifyModule registers routes in onInit. Plugins added in afterLoad are in place before routes are registered, so their onSend and onRequest hooks reliably wrap every handler. Anything else risks plugin hooks running on some routes and not others. All shipped http-fastify-* packages follow this rule; if you write your own, copy the pattern.
Production checklist
- Security headers wired. CSP doesn't disable wholesale; HSTS preload domain is registered if
preload: true. - CORS allow-list is explicit. No wildcard origins with
credentials: true. - Rate limit on Redis. Memory driver only in dev. Key extractor reads the right client-IP header.
- CSRF mode chosen.
double-submitif HTML is cached,session-storedif not. Body-field validation only indouble-submit. - Auth resolvers ordered by hot path. JWT before session before API key, typically.
- MFA challenge cache is shared.
cache-redis, notcache-memory. - Authz strategy unified. RBAC or policy, not both for the same resource.
Next Steps
- HTTP — controllers, guards, parameter resolvers
- HTTP CSRF — mode tradeoffs
- Auth — resolver chain
- MFA — factor pool, secret store contract
- Scaling out — rate-limit-redis, cache-redis, session-redis