Security Headers
Formation sets a small, identical-everywhere set of security response headers on every API and every web response. There are two implementation surfaces because the API and the web app have different output content types — but the baseline posture is the same.
| Header | API (SecurityHeadersMiddleware) | Web (hooks.server.ts) | Why |
|---|---|---|---|
Strict-Transport-Security: max-age=31536000; includeSubDomains | ✅ | ✅ | Browser locks the origin to HTTPS for a year. No preload — that’s irreversible. |
X-Content-Type-Options: nosniff | ✅ | ✅ | Browsers honour our Content-Type instead of guessing. |
X-Frame-Options: DENY | ✅ | ✅ | Defence-in-depth against clickjacking. Superseded by CSP frame-ancestors on HTML, but the API isn’t HTML and X-Frame is the lever. |
Referrer-Policy: strict-origin-when-cross-origin | ✅ | ✅ | Browser strips Referer on cross-origin; sends origin-only on same-origin TLS. |
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=() | ✅ | ✅ | Drops every browser feature we don’t use. The empty allow-lists also propagate to documents that load us. |
X-Permitted-Cross-Domain-Policies: none | ✅ | ✅ | Blocks legacy Flash/Acrobat cross-domain requests. We don’t serve those policy files but explicit none is cheap. |
Content-Security-Policy-Report-Only | ❌ | ✅ (HTML responses only) | CSP only applies to documents. The API is JSON-only — a CSP header there is moot, hence skipped. |
API middleware ordering
Section titled “API middleware ordering”SecurityHeadersMiddleware is registered first in Program.cs, before VersionHeaderMiddleware and everything that follows. That’s deliberate: we want short-circuited responses (auth 401s, the global exception handler’s 500s, CORS preflight rejections) to carry the same headers as a 200 from a controller. A missing HSTS on a 500 is exploitable in exactly the same way as a missing HSTS on a 200.
The middleware sets headers synchronously at the top of InvokeAsync, before await _next. Headers set on the response before any downstream layer starts writing the body are present on every outcome. If a future middleware ever clears or overwrites these headers further down, the fix is to switch to Response.OnStarting.
Web hook composition
Section titled “Web hook composition”hooks.server.ts composes via sequence(securityHeaders, handleSession(...)) — the security headers wrap around the session handler so they end up on every response, including the redirect that handleSession may emit when refreshing a cookie.
CSP is applied only when the response’s content-type starts with text/html. The hook also runs for SvelteKit’s static asset / API proxy paths, where a CSP would just be header weight with no document to apply it to.
CSP rollout plan
Section titled “CSP rollout plan”The CSP ships in Report-Only mode for the first deploy. The header name on the wire is Content-Security-Policy-Report-Only, which means browsers report violations to the console (and any future violation collector) without blocking anything.
The starter policy:
default-src 'self';script-src 'self' 'unsafe-inline';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' data:;connect-src 'self';frame-ancestors 'none';frame-src 'none';object-src 'none';base-uri 'self';form-action 'self'The 'unsafe-inline' allowances are the pragmatic baseline: SvelteKit emits inline <script type="module"> for hydration bootstrap, and Skeleton/Tailwind components inject inline styles. Tightening to nonces is a future improvement once the rest of the policy is settled.
When to flip to enforcing mode
Section titled “When to flip to enforcing mode”- Watch dev/uat browser consoles during real usage for one to two weeks. Each violation logs as
Refused to … because it violates the following Content Security Policy directive: …. - Fold legitimate sources into the CSP. Map-tile CDNs, third-party fonts (if any), and the Application Insights browser SDK (when browser RUM lands) all need their hosts added to the relevant
*-srcdirective. - Flip the header name in
hooks.server.ts:Content-Security-Policy-Report-Only→Content-Security-Policy. Re-deploy. - Watch for the same window of real usage in enforcing mode. Violations now block instead of just reporting.
What deliberately isn’t in the CSP
Section titled “What deliberately isn’t in the CSP”report-uri/report-to. No violation collector is configured yet. Violations land in the browser console only. Once browser RUM is wired (Tier 1.4), App Insights’ browser SDK will pick them up. Adding a dedicated/api/csp-reportendpoint is a separate piece of work; not done here to keep the surface tight.- Nonces. SvelteKit can emit nonces via
csp.directivesinsvelte.config.js. Worth doing once'unsafe-inline'is the only blocker between us and a strict policy.
Trade-offs we made
Section titled “Trade-offs we made”'unsafe-inline'for bothscript-srcandstyle-src. SvelteKit hydration and Skeleton inline styles. Without this the HTML doesn’t render. Plan above moves us to nonces.- HSTS without
preload. Preload submits the domain to a hard-coded list shipped in Chromium. Removing it requires a Chrome release cycle, so it’s irreversible in practice. We’re not at the maturity level where that lock-in is desirable. - HSTS not gated on HTTPS. The spec says browsers MUST ignore HSTS on plain HTTP, so unconditionally setting it is harmless. Saves a request inspection.
- No CSP on the API. API responses are JSON. CSP on a non-document response is no-op header weight.