Security Headers
Security response headers are set by securityHeaders middleware in
src/start.ts via TanStack Start's request middleware. Values vary by
environment, controlled by process.env.NODE_ENV.
Headers (always set)
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options | DENY | Block all framing (clickjacking protection). |
X-Content-Type-Options | nosniff | Prevent MIME sniffing. |
Referrer-Policy | strict-origin-when-cross-origin | Limit referrer leakage. |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disable unused browser APIs. |
Content-Security-Policy | see below | XSS / injection mitigation. |
Production-only headers (NODE_ENV=production)
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Force HTTPS for 1 year, preload-eligible. |
Cross-Origin-Opener-Policy | same-origin-allow-popups | Process isolation against XS-Leaks while keeping Google/OAuth popups working. |
Cross-Origin-Resource-Policy | same-site | Block cross-site embedding while still allowing our own subdomains. |
HSTS is intentionally omitted in development so localhost over HTTP keeps working.
Cross-Origin headers — why these exact values
- COOP
same-origin-allow-popups(notsame-origin): Supabase social sign-in opens an OAuth popup that callswindow.opener.postMessageafter the redirect. Strictsame-originsevers that opener relationship and the popup can't deliver the auth result back. The relaxed variant keeps the cross-origin isolation guarantees we care about (XS-Leaks / Spectre) while preserving the popup channel. - CORP
same-site(notsame-origin): allows assets served from sibling subdomains (e.g.cdn.helixsecure.co.uk,*.lovable.apppreviews) to be embedded by the app.same-originwould block them outright. - COEP is intentionally NOT set. Enabling
Cross-Origin-Embedder-Policy: require-corpwould break Supabase Storage downloads, remote<img>sources, and Realtime websocket payloads, because those responses don't ship a matchingCross-Origin-Resource-Policyheader. Only enable COEP if/when the app needsSharedArrayBufferor high-resolution timers.
CORS
The app does not expose any public HTTP endpoints (no
app-side CORS by default — but src/routes/api/public/csp-report.ts exists,
and you may add more. Any new /api/public/* route MUST go through
src/lib/cors.ts (withCors / corsPreflight) instead of writing
Access-Control-* headers by hand.
The helper enforces:
Access-Control-Allow-Origin: *cannot be combined withAccess-Control-Allow-Credentials: true(throws at runtime).- Origins must be bare (
https://example.com, no path/trailing slash). Vary: Originis set whenever the response varies by origin.
Two tests back this up:
src/lib/__tests__/cors.test.ts— unit tests for the helper.src/routes/api/public/__tests__/no-wildcard-credentials.test.ts— static scan that fails the build if any/api/public/*file writes the unsafe combo or skips the helper.
Browser → Supabase REST/Realtime CORS is still handled by Supabase, and
server functions (createServerFn) are same-origin RPC — no CORS needed.
Referrer-Policy
- Global default:
strict-origin-when-cross-origin— sends only the origin (no path/query) on cross-origin requests, so Supabase seeshttps://helixsecure.co.uk(needed for its CORS check) but never the internal URL or query string. Keeps full URL on same-origin. - Auth/credential routes (
/login,/signup,/reset-password,/forgot-password,/verify-email): tightened tono-referrerso password-reset tokens and email-verification tokens in the URL never leak to any third party (analytics, fonts CDN, etc.).
CSP violation reporting
Production CSP includes both report-uri and report-to csp-endpoint,
paired with a Reporting-Endpoints: csp-endpoint="/api/public/csp-report"
header. Browsers POST violation reports to that endpoint; reports land in
the csp_reports table.
See docs/csp-reports.md for the triage playbook, SQL queries, and
retention guidance. Reporting is intentionally disabled in development.
Content-Security-Policy
Production CSP
default-src 'self';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com data:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
script-src 'self' 'unsafe-inline';
connect-src 'self' https://*.supabase.co wss://*.supabase.co;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
'unsafe-inline'onstyle-srcis required by shadcn/Tailwind runtime styles.'unsafe-inline'onscript-srcis required by TanStack Start's hydration script.'unsafe-eval'is not allowed in prod.connect-srcallows the Lovable Cloud (Supabase) REST + Realtime endpoints.upgrade-insecure-requestsauto-rewrites any accidentalhttp://subresource tohttps://.
Development CSP
Relaxed to make Vite HMR work:
script-srcadds'unsafe-eval'(React Refresh / Vite client).connect-srcaddsws:,wss:, andhttp://localhost:*(HMR socket).img-srcaddsblob:(dev tooling previews).
Changing headers
Edit src/start.ts. The isProd branch toggles HSTS + COOP/CORP and swaps
the CSP between cspProd and cspDev. Update this document when you change
any header.
Verifying
curl -sI https://helixsecure.co.uk/ | grep -iE 'strict-transport|content-security|x-frame|referrer|permissions|cross-origin'
Or use https://securityheaders.com/. Target grade: A or higher in production.
Per-route overrides
Sensitive routes can tighten headers/CSP without affecting the rest of the
app. Overrides live in securityOverrides in src/start.ts and match by
pathname prefix (first match wins).
export const securityOverrides: SecurityOverride[] = [
{
prefix: "/reset-password",
cspDirectives: {
// REPLACES the global script-src for this route only
"script-src": "'self'",
"connect-src": "'self' https://*.supabase.co",
},
headers: {
"cache-control": "no-store, no-cache, must-revalidate",
"referrer-policy": "no-referrer",
},
},
];
Rules:
cspDirectiveskeys are CSP directive names (script-src,connect-src, …). The value REPLACES the directive for that route; other directives (e.g.frame-ancestors 'none') stay intact.headerskeys are lowercase header names. They override the defaults set by the middleware.- Order matters — put more specific prefixes (e.g.
/admin/billing) before broader ones (e.g./admin).
Default overrides shipped
| Route prefix | Why |
|---|---|
/login, /signup, /forgot-password, /verify-email | cache-control: no-store, referrer-policy: no-referrer to keep credentials/tokens out of caches and referrers. |
/reset-password | Above + tighter CSP: script-src 'self' (no inline JS allowed), connect-src limited to backend only. The reset-token URL must never leak. |
Add new overrides as you build sensitive pages (admin billing, key rotation, impersonation, etc.). Keep this table in sync.