-
Default to a strict, nonce- or hash-based CSP — host allowlists are obsolete and bypassable. Allowlist CSPs (script-src 'self' cdn.example.com) are trivially defeated via JSONP endpoints, open redirects, or AngularJS on a whitelisted host (Google's own research found ~94% of allowlist CSPs bypassable). The strict pattern:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
report-uri /csp-report; report-to csp
'strict-dynamic' lets a nonced/hashed script load further scripts it creates, so you don't enumerate every CDN. When present, browsers that understand it ignore https: and 'unsafe-inline' — those are fallbacks for old browsers only, not a real relaxation.
object-src 'none' kills Flash/<object> injection; base-uri 'none' stops <base href> from rewriting relative script URLs.
- You usually don't need
default-src micromanaged once script-src is strict; the dangerous directive is script execution.
-
Generate a fresh 128-bit nonce per response and stamp it on every inline <script>. The nonce must be cryptographically random and unique per HTTP response (never reuse, never hardcode) — a static nonce is equivalent to 'unsafe-inline'.
| Stack | Generate | Apply |
|---|
| Express | res.locals.nonce = crypto.randomBytes(16).toString('base64') | helmet contentSecurityPolicy with (req,res)=>nonce;
|
| Next.js | nonce in middleware.ts, pass via header | Next injects nonce into its own scripts when CSP header has a nonce |
| Django | django-csp @csp_update / {{ request.csp_nonce }} | <script nonce="{{ request.csp_nonce }}"> |
| Rails | config.content_security_policy_nonce_generator | javascript_tag nonce: true / nonce: true in tags |
| Go/Caddy/nginx | per-request var (sub_filter or middleware) | template the nonce into markup |
For static/cached HTML where you can't inject a per-response nonce, use 'sha256-...' hashes of each inline script's exact bytes instead (compute at build time). Nonces require dynamic rendering; hashes work on a CDN.
-
Roll out in Report-Only first — never flip enforcing CSP straight to prod. Ship Content-Security-Policy-Report-Only (same policy) alongside any existing enforced policy, collect violations for days/weeks, fix legitimate breakage, then promote to the enforcing header. Wire reporting with the modern report-to (a Reporting-Endpoints header naming a collector) and keep deprecated report-uri for older browsers:
Reporting-Endpoints: csp="https://example.com/csp-report"
Content-Security-Policy-Report-Only: script-src 'nonce-...' 'strict-dynamic'; report-to csp; report-uri /csp-report
Expect noise from browser extensions injecting inline scripts — triage by blocked-uri/source-file; don't widen the policy to silence extension reports.
-
Set frame-ancestors to control framing — it supersedes X-Frame-Options. frame-ancestors 'none' (no framing) or frame-ancestors 'self' https://trusted.example.com (allow specific embedders). Browsers honor frame-ancestors over the legacy X-Frame-Options: DENY|SAMEORIGIN when both exist; keep X-Frame-Options: DENY only as a fallback for ancient clients. XFO has no allowlist-multiple-origins capability — frame-ancestors is the real control.
-
Pin the rest of the header set — each closes a specific class.
| Header | Value (strong default) | Closes |
|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | SSL-strip / downgrade; mandates HTTPS for 2y |
X-Content-Type-Options | nosniff | MIME-sniffing a JSON/text response into executable HTML/JS |
Referrer-Policy | strict-origin-when-cross-origin (or no-referrer) | leaking full URL + query (tokens) in Referer |
Permissions-Policy | camera=(), microphone=(), geolocation=(), interest-cohort=() | abuse of powerful features; deny-by-default () = nobody |
Cross-Origin-Opener-Policy | same-origin | cross-window attacks; required (with COEP) to re-enable SharedArrayBuffer |
Cross-Origin-Resource-Policy | same-origin (or same-site) | side-channel/Spectre cross-origin reads (data leak) |
X-Frame-Options | DENY (legacy fallback only) | clickjacking on old browsers (else use frame-ancestors) |
HSTS rules: only send over HTTPS; includeSubDomains covers every subdomain (verify they're all HTTPS first); preload is a near-irreversible commitment — once on the browser preload list, removal takes months, so don't add it until you're certain all subdomains are HTTPS-only. Submit at hstspreload.org. Permissions-Policy replaces the old Feature-Policy; interest-cohort=() opts out of FLoC/Topics.
-
CORS: echo exactly one validated origin — NEVER wildcard with credentials. The single most common CORS vuln is Access-Control-Allow-Origin: * (or reflecting Origin blindly) together with Access-Control-Allow-Credentials: true, which lets any site read authenticated responses.
- The spec forbids
* + credentials — but reflecting the Origin header unchecked is the same hole. Validate the incoming Origin against an allowlist, and only then echo it back: Access-Control-Allow-Origin: <that exact origin> + Vary: Origin.
- Never trust substring/regex matches like
endsWith('.example.com') (matches evilexample.com) or startsWith('https://example.com') (matches https://example.com.evil.com). Match the full origin against an explicit set.
- If you don't need credentials, prefer
Access-Control-Allow-Origin: * without credentials — that's safe and simpler. Don't reflect null (sandboxed iframes/file:// send Origin: null — allowlisting null is exploitable).
- Set
Vary: Origin whenever the ACAO value depends on the request, or a cache will serve one origin's allowed-response to another.
-
Harden cookies: Secure; HttpOnly; SameSite — and __Host- for session cookies. Every session/auth cookie:
Set-Cookie: __Host-session=...; Secure; HttpOnly; SameSite=Lax; Path=/
Secure — only sent over HTTPS. HttpOnly — invisible to document.cookie, so an XSS can't exfiltrate it. SameSite=Lax (default-safe; blocks cross-site POST CSRF) or Strict for the most sensitive; use SameSite=None; Secure only for genuine cross-site cookies (and then you need CSRF defense).
- The
__Host- prefix forces Secure, Path=/, and no Domain — the browser rejects the cookie if those aren't met, preventing subdomain cookie-fixation. Use it for session cookies. __Secure- is the weaker variant (just requires Secure).
-
Set headers once, at the right layer, and don't let it get clobbered. Prefer a single source of truth: app middleware (helmet / secure_headers / django-csp) or the edge/proxy — not both fighting. If a CDN/WAF (setup-cdn-edge-waf) or reverse proxy (configure-reverse-proxy-lb) also injects headers, confirm which wins (proxies often append, producing duplicate/conflicting CSP — the browser then enforces the intersection, which can silently break the page). Apply headers to all responses including errors, redirects, and API/JSON. Use helmet (Express), secure_headers gem (Rails), django-csp + SecurityMiddleware (Django), or securityheaders middleware (Go) rather than hand-rolling.