---
status: accepted
date: 2026-05-13
decision-makers: R&D Lead
tags: [security, backend]
---

# Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope

## Context and Problem Statement

[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) noted that the BFF in v1 ships with a minimal CORS allowlist and that the security-hardening stack — helmet, real allowlist, CSRF, rate limiting, structured error filter — would land "with the phase-2 security ADR". That phase-2 security ADR (the **baseline**, in the sense of "which ASVS level does the project target, which adjacent frameworks apply") is **paused** until the RSSI clarifies the reference level and the regulatory perimeter (HDS, GDPR, possibly PCI DSS / NIS 2).

In the meantime, the BFF accumulated five PRs that landed concrete security middleware (#115 absolute-timeout, #117 SPA route guard + credentials interceptor, #122 helmet + CORS + CSRF, #123 rate-limit + structured error filter). Each PR captured its own rationale, but the choices live in code comments and PR descriptions — there is no single piece of documentation that says "this is why CSRF is session-bound", "this is the response envelope contract", "this is the rate-limit bucket strategy". A future contributor reaching for `csurf` or `cookie-only CSRF` because they don't know better is the kind of slip this ADR is meant to prevent.

This ADR records **the concrete implementation choices** made in those five PRs. It is deliberately one notch below the strategic baseline (ASVS level, regulatory framing, RSSI sign-off) — that one lands separately when the RSSI input arrives, and this one becomes its v1 reference for "what the BFF actually does today".

## Decision Drivers

- Capture rationale while the memory is fresh — most of these choices are non-obvious without their context.
- Provide a single landing point for a security review (internal or external).
- Single response contract for every error so the SPA has one shape to parse and so 5xx never leak internals.
- Defense in depth: each middleware addresses a distinct attack class, and removing one shouldn't open the others.
- Sensible-by-default in production, fail-loudly-on-misconfiguration at boot.

## Considered Options

### Response envelope on errors

- **`{ error: { code, message, traceId } }` shared by Nest's exception filter and raw middleware (chosen).**
- Per-route ad-hoc shapes (the pre-PR baseline).
- Nest's default `{ statusCode, message, error }` at the top level.

### CSRF strategy

- **Session-bound double-submit (server-side session token + JS-readable cookie + `X-CSRF-Token` header, comparison against the session token, not the cookie) (chosen).**
- Pure cookie-vs-header double-submit (no server-side storage).
- Synchronizer token pattern (server-side token, rendered into HTML — N/A, the BFF doesn't render HTML).
- `csurf` package.

### CORS allowlist

- **Mandatory env-driven allowlist via `CORS_ALLOWED_ORIGINS`, parsed and validated at boot, no fallback (chosen).**
- Hardcoded localhost fallback with env override.
- Open `*` policy gated by `credentials: false`.

### Rate limiting bucket key

- **`sessionID` when authenticated, remote IP otherwise (chosen).**
- IP only.
- User id (Entra `oid`) when authenticated.

### Rate limiting tiers

- **Two tiers via dynamic `max` per request: 10/min strict on `/auth/login` + `/auth/callback`, 120/min general (chosen).**
- One uniform limit.
- Per-route limits via separate `rateLimit()` instances.

### Rate limiting store

- **In-memory (single-instance default of `express-rate-limit`) (chosen for v1).**
- Redis-backed (`rate-limit-redis`).

### Helmet CSP

- **`helmet()` defaults in production, `contentSecurityPolicy: false` in development (chosen).**
- Custom CSP tailored to the BFF.
- Disabled everywhere (Nest's default has no CSP either).

## Decision Outcome

The BFF runs five middleware in this order, mounted in `apps/portal-bff/src/main.ts`:

1. **`StructuredErrorFilter`** (registered via `app.useGlobalFilters` at the top of `bootstrap()`) — normalises every NestJS exception to `{ error: { code, message, traceId } }`. Source of truth for the response envelope.
2. **`helmet()`** — security headers. Three overrides:
   - `hsts` only in production (dev runs on plain HTTP),
   - `crossOriginResourcePolicy: 'cross-origin'` (SPA on its own origin reads JSON from the BFF),
   - `contentSecurityPolicy: false` outside production (the BFF returns JSON; the default CSP triggers noisy `connect-src` violations in devtools without protecting anything we care about on JSON responses).
3. **CORS allowlist** — `CORS_ALLOWED_ORIGINS` env-driven, mandatory at boot. Parsed + validated (`http(s):` only, bare origins, no path/query). No localhost fallback. `X-CSRF-Token` declared in `allowedHeaders` alongside `Content-Type / Accept / Authorization / traceparent / tracestate`.
4. **Rate limiting** (`express-rate-limit`) — dynamic `max` per request: 10/min on the auth-flow entry routes, 120/min everywhere else, `/api/health` skipped. Bucket key = session id when `req.session.user` is set, else remote IP. In-memory store; v2 swaps for `rate-limit-redis` when we scale BFF horizontally. Response = same envelope as the filter (`code: 'rate_limited'`).
5. **CSRF middleware** (custom; not `csurf`, which is unmaintained) — double-submit, **session-bound**: token minted at session creation in `/auth/callback`, stored on `req.session.csrfToken` AND mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). Request middleware compares the `X-CSRF-Token` header against the **session-stored** token with `crypto.timingSafeEqual`. Skips safe methods (`GET / HEAD / OPTIONS`), unauthenticated requests, and the auth entry routes that mint the token (`/api/auth/login`, `/api/auth/callback`).

The order is intentional:

- The filter is registered first so it catches errors from any subsequent middleware initialisation.
- helmet → CORS → … so headers are set on every response, including pre-flight `OPTIONS`.
- Rate limit → CSRF: rate-limit rejects without paying the CSRF comparison cost; on an already-blocked client we don't burn cycles.
- Both raw middlewares (rate-limit, CSRF) use the shared `errorResponse()` helper exported by `StructuredErrorFilter` so the wire format is identical whether the response came through the filter or through a middleware short-circuit.

### Response envelope contract

```json
{
  "error": {
    "code": "csrf",
    "message": "CSRF token missing or invalid",
    "traceId": "abc123…"
  }
}
```

- **`code`** is the SPA's switch-on key. Stable strings: `unauthenticated`, `forbidden`, `not_found`, `csrf`, `rate_limited`, `bad_request`, `internal`, `service_unavailable`, and any custom code an `HttpException` declares via its response object (`new UnauthorizedException({ code: 'unauthenticated', message: '…' })`).
- **`message`** is safe, human-readable text. 5xx exceptions never put their internal message in the response — the full exception goes to the Pino `error` log line via the `err: exception` field; the response body says "Internal server error".
- **`traceId`** is the active OTel trace id (or `null`). Lets the user paste it into a support ticket and have ops correlate against Jaeger + audit rows + Pino lines in one query.

### Configuration (env-driven)

| Variable                     | Required         | Purpose                                                                                 |
| ---------------------------- | ---------------- | --------------------------------------------------------------------------------------- |
| `CORS_ALLOWED_ORIGINS`       | **yes**          | Comma-separated `scheme://host[:port]` list. BFF refuses to boot if absent / malformed. |
| `RATE_LIMIT_PER_MINUTE`      | no — default 120 | General bucket ceiling.                                                                 |
| `RATE_LIMIT_AUTH_PER_MINUTE` | no — default 10  | Stricter bucket for `/auth/login` + `/auth/callback`.                                   |

`LOG_USER_ID_SALT`, `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY` were promoted to mandatory at boot in earlier PRs; this ADR doesn't re-state them.

### Consequences

- Good, because every error response has the same shape; the SPA writes one branch to parse it.
- Good, because 5xx exception details never leak — the secure-by-default for unhandled errors.
- Good, because the CSRF cookie isn't trusted: an attacker who plants a cookie via subdomain takeover would still need the session-stored token, which is also the authenticated identity. Stronger than the canonical double-submit recipe.
- Good, because rate-limit buckets are per-account when authenticated; rotating sessions doesn't dodge the limit.
- Good, because boot-time validators on `CORS_ALLOWED_ORIGINS` catch the misconfiguration in dev / staging instead of in production.
- Bad, because in-memory rate-limit store means a multi-instance BFF would have per-instance counters. Mitigated by single-instance v1; the Redis-backed migration is one constructor arg.
- Bad, because the CSRF cookie must be readable by JS (`HttpOnly: false`), so an XSS that reads `document.cookie` can lift it. Mitigated by: (a) the session cookie remains `HttpOnly`, so an XSS still needs to call the BFF from the victim's browser, which means the user has to be present; (b) CSP and helmet defaults harden the SPA delivery; (c) the SPA bundle is small + audited; (d) tighten further by introducing a CSP nonce when admin SPA renders.
- Bad, because the structured envelope doesn't include a stable per-error machine-readable detail object yet (e.g. for ValidationPipe rejections, the `message` is a semicolon-joined string). A `details` field can be added without breaking the contract.
- Neutral, because no rate limiting on anonymous mutating routes today — none exist yet. The check will move to "per-IP for unauthenticated requests" if and when those routes appear.

### Confirmation

- `apps/portal-bff/src/main.ts` mounts the five middleware in the declared order; the comment block in `bootstrap()` lists their roles and rationale.
- `apps/portal-bff/src/security/structured-error.filter.ts` carries the `ErrorResponseBody` interface + `errorResponse()` helper. Used by `csrf.middleware.ts` (line: `res.status(403).json(errorResponse('csrf', '…'))`) and by `rate-limit.middleware.ts` (handler line).
- `apps/portal-bff/src/security/csrf.middleware.ts` reads from `req.session.csrfToken` (set in `apps/portal-bff/src/auth/auth.controller.ts` at the end of `/auth/callback`); the spec pins constant-time comparison + the exempt-paths list.
- `apps/portal-bff/src/security/rate-limit.middleware.ts` exports `readRateLimitConfig()` (env parsing + validation) and `createRateLimitMiddleware()` (factory); specs cover the bucket key strategy, the strict-vs-general tiers, the `/api/health` skip, and the 429 envelope shape.
- `apps/portal-bff/src/config/check-cors-allowlist.ts` enforces the boot-time validation; spec covers every rejected case (empty, non-URL, non-http(s), path/query present).
- The SPA `csrfInterceptor` in `libs/feature/auth/src/lib/csrf.interceptor.ts` reads the cookie and copies it into the header on every mutating BFF request; the spec asserts the interceptor never touches non-BFF origins or safe methods.
- Integration tests sit in the next phase: today the contract is held by unit-level specs (199 / 199 BFF, 28 / 28 feature-auth) under a clean-env repro (`env -u` every config var). A future "BFF smoke" test will black-box-verify all five middleware on a running stack.

## Pros and Cons of the Options

### Response envelope

#### `{ error: { code, message, traceId } }` (chosen)

- Good, because single shape across status codes and across Nest-vs-middleware origins.
- Good, because `traceId` makes any reported error joinable to the full request trace.
- Good, because the `code` field lets the SPA write a `switch` instead of pattern-matching on free-form messages.
- Bad, because no machine-readable `details` for field-level validation errors yet (mitigated by adding it later without changing the envelope).

#### Nest default `{ statusCode, message, error }`

- Good, because zero work.
- Bad, because `message` may be an array (ValidationPipe), a string, or an object depending on the exception path. Inconsistent for the SPA.
- Bad, because 500s leak the exception class name in `error`.

#### Per-route ad-hoc shapes

- Bad, because every route invents its own contract; the SPA has to special-case each path.

### CSRF strategy

#### Session-bound double-submit (chosen)

- Good, because resistant to subdomain cookie injection: a planted cookie doesn't match the session-stored token.
- Good, because no shared-state needed beyond the session payload we already store in Redis.
- Bad, because requires server-side storage (we have it anyway).

#### Pure cookie-vs-header double-submit

- Good, because completely stateless on the server.
- Bad, because vulnerable to subdomain cookie injection: an attacker who can write `__Host-portal_csrf` cookie value (e.g. via subdomain takeover) can also generate the matching header.

#### `csurf` package

- Bad, because deprecated / unmaintained since 2022. Not an option.

#### Synchronizer token rendered in HTML

- Bad, because the BFF doesn't render HTML — the SPA is a separate static bundle.

### CORS allowlist

#### Mandatory env-driven, no fallback (chosen)

- Good, because forces explicit configuration in every environment.
- Good, because boot-time validator catches the misconfiguration before request handling.
- Bad, because slightly less ergonomic in fresh-clone setup (developers need to read `.env.example`; mitigated by the validator's error message pointing straight at the file to edit).

#### Hardcoded localhost fallback

- Good, because zero-config dev.
- Bad, because "works in dev, breaks in prod" trap — production silently allows nothing if the env is forgotten.

#### `*` policy

- Bad, because `credentials: true` + `*` is incompatible per the CORS spec, and `credentials: false` defeats the session cookie.

### Rate limiting bucket key

#### `sessionID` if auth, else IP (chosen)

- Good, because per-account fairness for authenticated users (one user can't drown other users from the same NAT'd IP).
- Good, because IP fallback protects against unauthenticated brute force on `/auth/login`.

#### IP only

- Bad, because a single NAT-shared IP (corporate proxy, mobile carrier) shares its quota across users — one noisy user blocks everyone.

#### Entra `oid`

- Good, because tied to the user identity rather than the session, so logout + login doesn't reset the quota.
- Bad, because `oid` is only available post-resolution. `sessionID` is available immediately and resolves to the same user via the session payload. Equivalent in practice; sessionID is what `express-rate-limit` already has on `req`.

### Rate limiting store

#### In-memory (chosen for v1)

- Good, because zero infrastructure.
- Bad, because per-instance counters when we scale out (mitigated by single-instance v1).

#### Redis-backed

- Good, because shared counters across BFF instances.
- Bad, because new infra dep + each request adds a Redis hop.

### Helmet CSP

#### Defaults in prod, disabled in dev (chosen)

- Good, because production responses carry a sensible CSP without bespoke tuning.
- Good, because dev devtools stay clean (Helmet's default `connect-src 'self'` triggers cosmetic violations on JSON responses).
- Bad, because dev / prod differ — a CSP misconfiguration won't be caught locally.

#### Custom CSP

- Good, because matches the actual BFF response shape (JSON, no scripts, no images).
- Bad, because more surface to maintain; revisited when admin SPA is served by Caddy with a real CSP nonce.

## More Information

- OWASP CSRF Cheat Sheet (double-submit + synchronizer token comparison): https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Rate Limiting Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html
- `express-rate-limit` docs: https://express-rate-limit.mintlify.app/
- `helmet` defaults reference: https://helmetjs.github.io/
- Related ADRs: [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (session cookie + CSRF design), [ADR-0010](0010-session-management-redis.md) (session storage), [ADR-0012](0012-observability-pino-opentelemetry.md) (trace id propagation, used here as the error envelope's correlation key), [ADR-0015](0015-cicd-gitea-actions.md) (DoS mitigation reference); future: the **paused** strategic security baseline ADR (ASVS level + regulatory framing), the infrastructure ADR (Redis-backed rate-limit store, CSP for admin SPA).
