Documents the security middleware stack shipped across the five most
recent BFF PRs (#115, #117, #120, #122, #123) as a single MADR ADR.
Today the rationale lives in code comments and PR descriptions; the
next contributor reaching for `csurf`, cookie-only CSRF, or a
hardcoded localhost CORS fallback didn't have one place to read why
those are wrong here.
Contents:
- Response envelope: { error: { code, message, traceId } }, single
contract across Nest's filter and raw middlewares via the
errorResponse() helper. Status-code mapping documented. 500s
never leak the underlying exception.
- CSRF session-bound double-submit, not cookie-vs-header. The
cookie is the SPA's read-only mirror; the source of truth is
req.session.csrfToken — subdomain-takeover injection can't
bypass.
- CORS allowlist env-driven, mandatory, no fallback. Catches the
"works in dev breaks in prod" misconfiguration at boot.
- Rate limit: sessionID-then-IP bucket key, 10/min auth +
120/min general, /api/health skipped, in-memory v1 store.
- Helmet defaults + three overrides (HSTS prod-only, COR
cross-origin, CSP prod-only) with code-anchored justifications.
Considered options mirror the actual debate — each rejected
alternative has its "Bad, because" line so a reader sees why we
didn't go that way. Each Decision Outcome line cross-references
the file that enforces it.
Scope is the IMPLEMENTATION-level baseline. The STRATEGIC baseline
(ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off)
remains paused per CLAUDE.md. When it lands it confirms or
supersedes pieces of 0021; the ADR is explicit about which choices
are tactical.
Index entry in docs/decisions/README.md updated in the same commit
per the project convention.
CLAUDE.md §"Repository status" still says the workspace is "not
yet bootstrapped" and refs ADRs up to 0020 — that paragraph is
stale (22 ADRs in place, project fully scaffolded). Worth a small
dedicated docs PR; out of scope here.