Skip to content

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:

  1. Security headers — set on every response, regardless of route or status.
  2. CORS — bounce disallowed origins before route handlers run.
  3. Rate limit — drop floods before they reach business logic.
  4. CSRF — verify state-changing requests aren't cross-origin forgeries.
  5. Authentication — resolve a caller identity, or reject.
  6. MFA — require a second factor on sensitive endpoints.
  7. 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.

typescript
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 defaultorigin: false rejects every cross-origin request. Open it explicitly:

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

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

ModeToken shapeCacheable HTML?When
session-storedRandom, kept in session.dataNoDefault. Dynamic per-user HTML, server-rendered forms.
double-submitHMAC-SHA256(secret, sessionId)YesPages cached at the CDN; thin server + SPA.
typescript
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.

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

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

  1. User authenticates → session is created with mfa: false.
  2. App routes return 403 for @RequireMfa() routes; client redirects to MFA challenge.
  3. App calls MfaService.challenge(identity, 'totp') → returns a MfaChallengePayload with challengeId and factor-specific hints.
  4. User submits response → app calls MfaService.verify(challengeId, response).
  5. On success, the app stores an MFA-verified flag on the session (e.g. session.data.mfa = true) and ideally rotates the session id via sessionService.regenerate(session.id). The auth resolver that handles this session must read the flag back onto request.auth.mfa for subsequent requests — MfaGuard only looks at request.auth.mfa, not at session storage.
  6. 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-policyPolicy classes 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, not onInit.

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

  1. Security headers wired. CSP doesn't disable wholesale; HSTS preload domain is registered if preload: true.
  2. CORS allow-list is explicit. No wildcard origins with credentials: true.
  3. Rate limit on Redis. Memory driver only in dev. Key extractor reads the right client-IP header.
  4. CSRF mode chosen. double-submit if HTML is cached, session-stored if not. Body-field validation only in double-submit.
  5. Auth resolvers ordered by hot path. JWT before session before API key, typically.
  6. MFA challenge cache is shared. cache-redis, not cache-memory.
  7. 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