Skip to content

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.

HeaderAPI (SecurityHeadersMiddleware)Web (hooks.server.ts)Why
Strict-Transport-Security: max-age=31536000; includeSubDomainsBrowser locks the origin to HTTPS for a year. No preload — that’s irreversible.
X-Content-Type-Options: nosniffBrowsers honour our Content-Type instead of guessing.
X-Frame-Options: DENYDefence-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-originBrowser 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: noneBlocks 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.

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.

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.

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.

  1. 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: ….
  2. 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 *-src directive.
  3. Flip the header name in hooks.server.ts: Content-Security-Policy-Report-OnlyContent-Security-Policy. Re-deploy.
  4. Watch for the same window of real usage in enforcing mode. Violations now block instead of just reporting.
  • 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-report endpoint is a separate piece of work; not done here to keep the surface tight.
  • Nonces. SvelteKit can emit nonces via csp.directives in svelte.config.js. Worth doing once 'unsafe-inline' is the only blocker between us and a strict policy.
  • 'unsafe-inline' for both script-src and style-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.