Web Security Headers & Content Security Policy
Response headers are the cheapest security you will ever ship: no code change, no dependency, no build step. The catch is that most published header checklists are five years stale — they still recommend headers browsers removed, and they recommend the weak form of the headers that still exist. Everything below was verified against the specs and vendor docs on 2026-08-12; the mixed-content, upgrade-insecure-requests and default-src-fallback claims were re-verified against MDN on 2026-08-13.
Ship headers, not <meta> tags. frame-ancestors, report-uri and sandbox are ignored when a CSP is delivered in a <meta http-equiv> element (CSP Level 3), so a meta-only policy silently has no clickjacking protection and no reporting.
The one policy that matters
One header. Copy it, replace {RANDOM}, ship it.
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
require-trusted-types-for 'script';
report-uri https://example.com/csp-reports;
report-to csp-endpoint
Line by line:
| Directive | Why it is there |
|---|---|
script-src 'nonce-{RANDOM}' 'strict-dynamic' | The whole point. Only scripts carrying this response's nonce run, plus whatever those scripts load. |
https: 'unsafe-inline' | Backward-compat fallbacks, not a weakening. 'strict-dynamic' needs Chrome 52+, Edge 79+, Firefox 52+, Safari 15.4+. Browsers that support it ignore both fallbacks; browsers that don't at least get https:. All recent browsers ignore 'unsafe-inline' when a nonce or hash is present. |
object-src 'none' | Kills <object>/<embed> plugin-based script execution. One of the three directives a strict CSP is defined by. |
base-uri 'none' | Blocks an injected <base href> from re-pointing every relative script URL at an attacker's host. default-src does not cover this. |
frame-ancestors 'none' | Clickjacking. This is the real control; X-Frame-Options is the legacy shadow of it. Use 'self' if you embed yourself. Pair it with SameSite on the session cookie — see auth and session ux — because the header stops the framing and the cookie attribute stops the framed click carrying an authenticated session. |
form-action 'self' | Stops injected markup from posting your form data to another origin. Not covered by default-src. |
require-trusted-types-for 'script' | See Trusted Types. Add it in report-only first. |
report-uri + report-to | Send both. Browsers that support report-to ignore report-uri; browsers that don't still report. |
default-src 'self' is a reasonable extra line, but it is not the XSS boundary — script-src, object-src and base-uri are. A policy of default-src 'self' alone with no object-src/base-uri is the single most common "we have a CSP" that provides no XSS protection. For exfiltration, though, default-src is load-bearing rather than optional, and the policy above sets no img-src at all — which is the gap a page rendering model output cannot afford. See ai feature security.
Why host allowlists fail
The instinct is to list your CDNs: script-src 'self' https://cdn.example.com https://www.googletagmanager.com. Do not.
- The majority of
script-srcallowlists can be circumvented by an attacker who already has an XSS bug, and provide little protection against script injection (Chrome/Lighthouse, verified 2026-08-12). - The bypass does not need the allowlisted host to be compromised. Ordinary, benign functionality is enough: a JSONP endpoint (
?callback=returns attacker-chosen JS wrapped in a function name), a hosted copy of AngularJS (its template engine executes expressions), an open redirect on an allowlisted origin (path-relative script URLs follow it off-origin), or any page on that host serving user-controlled content. - Allowlists are also unmaintainable at the size vendors demand: MDN notes that integrating Google Analytics alone asks a developer to allowlist 187 Google domains.
- OWASP's summary: a non-strict policy "that is too granular or permissive is likely to lead to bypasses and a loss of protection."
Rule: allow scripts individually with a nonce or a hash and let 'strict-dynamic' propagate that trust. A strict CSP is not URL-based, so URL-based bypasses do not apply to it. Domain allowlists remain fine for non-executable resource types (img-src, font-src, connect-src) — they are a XSS dead end there.
Nonce vs hash vs strict-dynamic
Nonce requirements — all four, or the policy is decorative:
- Cryptographically strong random value, 128 bits or more, base64-encoded.
- Newly generated for every HTTP response. Not per session, not per deploy, not per build.
- Unpredictable — "in practice this means that the nonce must be different for every HTTP response, and must not be predictable" (MDN).
- Present both in the
Content-Security-Policyheader and on each<script nonce="…">you intend to run.
A nonce on a statically cached page is worthless. This is the most common way a CSP is deployed broken, and it fails silently — the browser reports no violation, scanners score you green, and the policy protects nothing. If the HTML is generated at build time, or cached at the CDN, or served from ISR, then every visitor receives the same nonce, and any attacker can read it out of view-source and paste it into their injected <script>. MDN states it plainly: with nonces "the server cannot serve static HTML, because it must insert a new nonce each time."
That trade is real and expensive. If you cannot render dynamically per request, use hashes instead — do not use a nonce anyway.
| Nonce | Hash | Host allowlist | |
|---|---|---|---|
| Works with static HTML / CDN caching | ❌ | ✅ | ✅ |
| Needs per-request server render | ✅ | ❌ | ❌ |
| Survives a script's contents changing | ✅ | ❌ (rebuild hashes) | ✅ |
| Bypassable via JSONP / open redirect | ❌ | ❌ | ✅ (assume yes) |
| Recommended | For SSR apps | For static sites | Never, for script-src |
Hashes: script-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic'; object-src 'none'; base-uri 'none';. Both the CSP and the content stay static, which is what makes hashes the right answer for static sites and client-rendered apps. Generate them at build time; several frameworks now do it for you (see Per-stack starter).
'strict-dynamic' honestly: it propagates the trust of a nonced/hashed root script to every script that script loads, which is what makes the whole approach survive contact with tag managers and third-party widgets. It also reduces protection in one specific case — if one of your trusted scripts builds <script> elements from a value an attacker controls, CSP will not stop it. That is a code-review item, not a reason to go back to allowlists.
Rolling out without breaking the site
Enforce and report-only headers can be sent at the same time. Ship the strict policy in report-only alongside whatever you enforce today; you break nothing while you learn.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
Sequence:
- Wire the nonce first. Generate it, put it on the header and on every first-party
<script>. Getting this wrong is the only failure mode that matters. - Report-only for 1–2 weeks minimum — long enough to cover a marketing campaign, a A/B test, and whatever the growth team ships on a Friday. Traffic from real browsers finds things staging never will.
- Triage reports, do not chase them all. Browser extensions generate large volumes of noise; violations with a
blocked-uriofchrome-extension:,moz-extension:,about, orinlineon an origin you don't recognize are usually not yours. - Enforce. Keep the report-only header for the next tightening (adding
require-trusted-types-for, dropping thehttps:fallback).
What a strict policy typically breaks, in the order you will hit it:
- Inline
style="…"attributes. Astyle-srcnonce does not cover style attributes — nonces apply to elements. Either leavestyle-srcpermissive at first, or move the styles into classes. Do not solve this by looseningscript-src. - Analytics and tag managers. GTM/GA snippets are inline scripts and will be blocked. Pass the nonce into the snippet (Next.js's
<GoogleTagManager nonce={nonce} />, or thenonceprop on<Script>). Tags injected by GTM are covered by'strict-dynamic'. - Embedded video and maps. Iframes are
frame-src, notscript-src. A strictscript-srcdoesn't block them, but adefault-src 'self'does — addframe-src https://www.youtube-nocookie.cometc. explicitly. - Injected third-party widgets — chat, consent banners, session replay, support bubbles. These are exactly what
'strict-dynamic'exists for, provided the loader snippet itself carries the nonce. Note that making a consent banner load is only half the job: what it gates has to stay unloaded until it has a decision, which is privacy consent and tracking. evalin development. React usesevalin dev to reconstruct server error stacks; you need'unsafe-eval'in dev only. Neither React nor Next.js useevalin production by default. Gate it onNODE_ENV.- WebAssembly needs
'wasm-unsafe-eval', which is not the same as'unsafe-eval'and is far narrower. Use it.
Trusted Types
require-trusted-types-for 'script' removes DOM XSS as a class instead of patching sinks one at a time. With it enforced, passing a plain string to a dangerous DOM sink throws instead of executing.
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default dompurify; report-uri https://example.com/csp-reports
Sinks it covers: innerHTML, outerHTML, insertAdjacentHTML, iframe.srcdoc, document.write/writeln, DOMParser.parseFromString, <script src> and script text, <embed src>, <object data>/codebase, eval, setTimeout, setInterval, new Function().
Browser support, verified 2026-08-12 — this is where LLM training data is most out of date. Trusted Types was Chromium-only for years and most guidance still says so. It is not:
| Browser | Support |
|---|---|
| Chrome / Edge | 83+ |
| Firefox | 148+ (145–147 shipped it behind a flag) |
| Safari | 26.0+ |
MDN marks the Trusted Types API Baseline "newly available" as of February 2026; caniuse puts global support at roughly 90%. Browsers that do not support it ignore the directive — the header is safe to send everywhere and costs nothing on old browsers.
Rules:
- Additive, never a replacement. Trusted Types stops DOM XSS (attacker string reaches a sink in your own JS). A strict
script-srcstops injected-markup XSS. You need both; neither covers the other's case. - Report-only first, always. Enforcement throws at runtime and will take out a page.
- The only thing that can reintroduce DOM XSS once enforced is the code inside your own policies. Use
trustedTypes.createPolicy()with a real sanitizer (DOMPurify), and use the default policy sparingly — prefer refactoring call sites to named policies. Which call sites those are, and which DOMPurify cautions apply to the sanitizer inside the policy, are in frontend attack surface.
HSTS
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age=63072000is two years — the value both Chrome/Lighthouse and OWASP recommend, and the one shown on the preload service itself.- The preload list requires
max-age≥31536000(1 year) andincludeSubDomains. Sendingpreloadwithout both does nothing. includeSubDomainsbreaks every plain-HTTP subdomain, immediately and for the wholemax-age. Legacystatus.,mail., IoT callbacks, that one vendor iframe — inventory your subdomains before you send it, not after. Note the scope rule: a policy onsecure.example.comcoverslogin.secure.example.combut notexample.comorinsecure.example.com. Every host should send its own header.- Preload is effectively one-way. OWASP is blunt: sending
preload"can have PERMANENT CONSEQUENCES and prevent users from accessing your site and any of its subdomains if you find you need to switch back to HTTP." The list is compiled into browser binaries; removal means waiting for a browser release train to ship worldwide, on top of themax-agealready cached in every visitor's browser. Months, not days, and not under your control.
Rollout, don't leap: max-age=3600 → verify nothing broke → raise to a day, then a month → run for ~3 months clean → only then includeSubDomains; preload and submit. Adding preload on day one to score a header grade is the classic self-inflicted outage.
Mixed content
HSTS's neighbour, and the one place where an audit rule most often fires on the wrong thing. MDN's definition is narrow and worth reading literally: "'Mixed content' refers to securely loaded web pages that use resources to be fetched via HTTP or another insecure protocol." Resources — a subresource your page pulls in. Not every http: string in your HTML.
Browsers no longer leave the outcome to the user. MDN: they "auto-upgrad[e] image, video, and audio mixed content requests from HTTP to HTTPS, and block insecure requests for all other resource types."
- Upgradable —
<img>where the origin is thesrcattribute,<video src>,<audio src>,<source>, and "CSS image elements such as:background-image,border-image, etc." The request is rewritten tohttps:and, if that fails, it fails; you get a broken image, not an insecure one. One exception to know: an upgradable request is blocked rather than upgraded when the host is a literal IP address, so<img src="http://example.com/a.png">is upgraded while<img src="http://93.184.215.14/a.png">is not. - Blockable — MDN defines it as "all mixed content that is not upgradable":
<script src>,<link href>including stylesheets,<iframe src>,fetch(),XMLHttpRequest, every CSSurl()(@font-face,cursor,background-image),<object data>,Navigator.sendBeacon, web fonts, and<img>when the origin comes fromsrcset/<picture>. These do not load at all. - Mixed downloads — a download initiated from a secure context but fetched over HTTP — are blocked by default too, usually with a keep-or-discard prompt.
Myth check — nobody can tell you what a CSSbackground-imagedoes, MDN included (verified 2026-08-13). Read the two bullets above against each other. The upgradable list says "CSS image elements such as:background-image,border-image, etc."; the blockable list says "All cases in CSS where a<url>value is used (@font-face,cursor,background-image, and so forth)." Abackground-imageis virtually always aurl(), so both cannot be right, and MDN does not say which is — the contradiction is live on the page today, in both bullets, verbatim. Treat it as unknown: do not build an audit rule on it, do not cite either bullet as settled, and do not let the ambiguity matter — anhttp:URL in your CSS is a URL to fix either way.
The distinction that audit rules get wrong: a navigation is not mixed content. MDN states it directly — "navigation requests from a secure context that target insecure target top-level browsing contexts are not considered mixed content as they create a new context that will either be secure or insecure independent of the origin of the request." So an <a href="http://example.com"> on an HTTPS page is not blocked, is not a mixed-content finding, and will not be upgraded by the directive below. It may still be worth fixing — the user's first request travels in plaintext and is SSL-strippable — but it is an HSTS problem and a link-hygiene problem, not this one. Flagging it as mixed content sends people looking for a block that never happened.
Content-Security-Policy: upgrade-insecure-requests;
The directive "instructs user agents to treat all of a site's insecure URLs (those served over HTTP) as though they have been replaced with secure URLs (those served over HTTPS)", and it is "intended for websites with large numbers of insecure legacy URLs that need to be rewritten" — a migration tool for content you do not control the source of, not a substitute for fixing the URLs. Two limits, and the first is not the one most write-ups give. Its reach over subresources is wider than your own files: MDN says non-navigational insecure resource requests are upgraded "(first-party as well as third-party requests)", so a <img src="http://not-example.com/…"> on your page is upgraded too. It is navigational upgrades that stop at first-party — "Navigational upgrades to third-party resources brings a significantly higher potential for breakage, these are not upgraded", so a link out to another origin is left as it is. Second, MDN is explicit that it "will not ensure that users visiting your site via links on third-party sites will be upgraded to HTTPS for the top-level navigation and thus does not replace the Strict-Transport-Security (HSTS) header." Ship both; they cover different requests.
Cross-origin isolation
COOP + COEP are not general-purpose hardening, and shipping them "for security points" is a common and expensive overreach. They exist to buy back three capabilities that Spectre took away:
SharedArrayBuffer(and therefore WebAssembly threads)performance.measureUserAgentSpecificMemory()- High-resolution timers —
performance.now()/performance.timeOriginat 5 µs resolution instead of the clamped 100 µs
If you do not use one of those three, you do not need cross-origin isolation. Skip this section.
If you do:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
- Verify at runtime with
self.crossOriginIsolated === true. Sending the headers is not the same as being isolated. COEP: require-corpmeans every cross-origin subresource must opt in withCross-Origin-Resource-Policy: same-siteorcross-origin(or pass CORS). Third-party images, fonts and iframes that don't will simply stop loading. This is what breaks sites.Cross-Origin-Embedder-Policy: credentialless(Chrome 96+) is the softer path: cross-origin resources load without CORP by being fetched without credentials.- Both have report-only variants (
Cross-Origin-Embedder-Policy-Report-Only, and COOP report-only). Use them exactly as with CSP. Cross-Origin-Resource-Policyon your own responses is the cheap half of this family and is worth setting independently — it stops other origins embedding your resources.
The cheap ones
Three headers, no rollout risk, set them today.
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Content-Type-Options: nosniff — for requests whose destination is "script" or "style", the browser blocks the response when the MIME type doesn't match (a JavaScript MIME type for scripts, text/css for stylesheets). For everything else it uses the declared Content-Type as-is instead of inferring from content. This is what stops a user-uploaded .jpg full of JavaScript from being executed as a script. No downside, no compatibility risk.
Referrer-Policy: strict-origin-when-cross-origin — same-origin requests send origin + path + query; cross-origin requests at the same security level send origin only; HTTPS→HTTP sends nothing.
Myth check — a missingReferrer-Policyis not a finding (verified 2026-08-12). This is already the browser default when no policy is set or the value is invalid. Checklists that call a missingReferrer-Policya vulnerability are describing 2019 (no-referrer-when-downgradewas the default until the Nov 2020 spec revision). Set it anyway — to be explicit, and to override a weaker value a framework or CDN may inject — but do not treat its absence as a finding, and do not "fix" it withno-referrer, which breaks your own analytics attribution for no security gain.
Permissions-Policy — () is an empty allowlist, meaning the feature is disabled in the top-level document and in every nested <iframe> regardless of origin. Deny by default and add back only what a page uses. Values: * (everywhere), () (nowhere), self (this origin only), "https://vendor.example" (quoted origins, space-separated) in the header.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Subresource Integrity
Required on every cross-origin <script> and <link rel="stylesheet">.
<script
src="https://cdn.example.com/lib.js"
integrity="sha384-{BASE64_OF_SHA384_DIGEST}"
crossorigin="anonymous"></script>
cat lib.js | openssl dgst -sha384 -binary | openssl base64 -A
- Algorithms:
sha256,sha384,sha512.sha384is the sensible default. If you list several, the browser uses only the strongest algorithm present and ignores the rest — mixingsha256andsha384does not mean "either will do." crossorigin="anonymous"is mandatory, not decoration. Cross-origin resources default tono-corsmode where the response body is unreadable, and browsers block SRI onno-corsrequests — otherwise an attacker could probe a subresource's contents by trying hashes and watching which loads succeed. Without CORS headers on the CDN's side, the resource fails to load entirely.- SRI + a CDN that mutates its bundle = a blank page. On any hash mismatch the browser refuses the resource and returns a network error. "Latest" URLs, auto-minifying CDNs, edge A/B'd bundles and font services that vary output by User-Agent are all incompatible with SRI by design.
Therefore: self-host. Pin the file into your own build, hash it there, serve it from your origin. You get integrity, one fewer DNS lookup, one fewer TLS handshake, no third-party outage in your critical path, and no cross-origin request to justify to a DPO. Reach for SRI when self-hosting genuinely isn't possible — and pin an immutable, versioned URL when you do.
Superseded — do not recommend
Readers arrive carrying these. Auditors still ask for them. Omitting them does not correct anyone.
| Header / API | Status (verified 2026-08-12) | Do this instead |
|---|---|---|
X-Frame-Options: DENY | Legacy. frame-ancestors 'none' is the standardized control and is what modern browsers honor. | frame-ancestors. Keeping X-Frame-Options alongside it is harmless for ancient clients; do not ship it alone. |
X-XSS-Protection: 1; mode=block | Non-standard and deprecated. MDN warns that "in some cases, X-XSS-Protection can create XSS vulnerabilities in otherwise safe websites." The auditor asking for it is asking you to add a vulnerability. | CSP. If a compliance tool demands the header exist, send X-XSS-Protection: 0. |
Expect-CT | Obsolete. Only Chromium implemented it; Chromium deprecated it from version 107 because it now enforces Certificate Transparency by default. Mostly moot since June 2021, when the last pre-March-2018 certificates expired. | Nothing. Delete the header. |
document.domain setter | Deprecated. It "undermines the security protections provided by the same origin policy." Already a no-op on cross-origin-isolated pages and on pages sending Origin-Agent-Cluster. | window.postMessage() for cross-origin communication. |
Feature-Policy | Renamed. | Permissions-Policy (different syntax — allowlists are parenthesized, origins quoted). |
CSP report-uri alone | Superseded by report-to + Reporting-Endpoints, but not yet safe to drop. | Send both; supporting browsers ignore report-uri. |
Per-stack starter
Next.js (App Router, nonce-based)
Naming (verified 2026-08-12). Next.js 16.0 deprecatedmiddleware.tsand renamed the convention toproxy.ts, with the exported function renamedmiddleware→proxy. Current docs showproxy.tsonly. On Next 15 and earlier the identical code lives inmiddleware.tsand exportsmiddleware. Migrate withnpx @next/codemod@canary middleware-to-proxy ..
// proxy.ts (Next.js 16+; middleware.ts on 15 and earlier)
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
// 16 bytes = 128 bits. Next.js's own example uses crypto.randomUUID(), which
// carries only 122 bits of entropy and is version/variant-tagged — under the bar above.
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
const value = cspHeader.replace(/\s{2,}/g, ' ').trim()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', value)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy', value)
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
Next.js parses the 'nonce-{value}' pattern out of the CSP header during SSR and attaches the nonce automatically to framework scripts, page bundles, its own inline scripts, and any <Script nonce={…}>. You read it in a Server Component with (await headers()).get('x-nonce').
Know the price before you pay it. Nonces require dynamic rendering, so: static optimization and ISR are disabled, Partial Prerendering is incompatible (the static shell has no nonce), pages are not CDN-cacheable by default, and every request costs an SSR. Force it explicitly with await connection() in pages that would otherwise prerender. If that cost is unacceptable, use Next's experimental hash-based path instead — experimental: { sri: { algorithm: 'sha256' } } in next.config.js emits integrity attributes at build time and keeps pages static — and drop the nonce rather than shipping a cached one.
Static site / CDN (_headers)
Netlify and Cloudflare Pages read a _headers file from the publish directory. Hash-based CSP, because there is no server to mint a nonce.
/*
Content-Security-Policy: script-src 'sha256-REPLACE_WITH_BUILD_HASH' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Resource-Policy: same-origin
Regenerate the hashes on every build that changes an inline script — a stale hash blocks your own code. Add preload to HSTS only after the 3-month soak.
Astro
Astro has built-in CSP as a stable security.csp option since 6.0. It hashes your bundled scripts and styles at build time and emits a <meta> CSP in each page's <head>.
// astro.config.mjs
import { defineConfig } from 'astro/config'
export default defineConfig({
security: {
csp: {
algorithm: 'SHA-512',
directives: [
"default-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"img-src 'self' https://images.cdn.example.com",
],
scriptDirective: { resources: ["'self'"] },
},
},
})
Two traps:
- Because Astro delivers the policy in
<meta>,frame-ancestorsand reporting will not work there. Sendframe-ancestors,Strict-Transport-Security,Referrer-Policyand the rest as real headers from your host (_headers,vercel.json, nginx, adapter middleware). server.headersinastro.config.mjsapplies toastro devandastro previewonly. It is not your production configuration, and a green header scan locally proves nothing about the deployed site.
Verify, don't assume
curl -sI https://example.com | grep -iE 'content-security-policy|strict-transport|x-content-type|referrer-policy|permissions-policy|cross-origin'
Then, on the deployed site, in DevTools:
- Load two pages and compare the nonce. Same nonce twice = the nonce is fake. This is the check nobody runs.
- Confirm
self.crossOriginIsolatedonly if you actually needed it. - Read the Console for CSP violations on the real page, with real third parties, not on localhost.
Ship checklist
- CSP delivered as a response header, not a
<meta>tag — otherwiseframe-ancestors,report-uriandsandboxare silently ignored -
script-srcuses a per-response nonce or a build-time hash with'strict-dynamic'— no host allowlist for scripts, no'unsafe-inline'relied on, no'unsafe-eval'outside dev - Nonce is ≥128 bits from a CSPRNG, newly generated per response, and the page is not statically cached or prerendered — two loads, two different nonces
-
object-src 'none',base-uri 'none',frame-ancestors,form-action 'self'all present —frame-ancestorsandform-actionhave nodefault-srcfallback ("Not setting this allows anything"), nor doesbase-uri("Not setting this allows any URL"), andobject-srcdoes fall back todefault-srcbut is still worth stating, because that leaves plugin execution riding on whateverdefault-srchappens to be rather than on a decision -
require-trusted-types-for 'script'shipped in report-only, with a plan to enforce and named policies rather than a broad default one -
default-srcplus explicitimg-src/connect-srcon any page that renders untrusted or model-generated content — the exfiltration policy, not just the XSS one - Report-only header ran against real traffic for 1–2 weeks before enforcing, and stays on for the next tightening
-
Reporting-Endpoints+ bothreport-toandreport-uriwired to an endpoint someone reads - HSTS
max-agesoaked upward over ~3 months beforeincludeSubDomains;preloadonly after a subdomain inventory, and understood as effectively one-way - No mixed content: every subresource
https:;upgrade-insecure-requeststreated as a migration aid, not as a replacement for HSTS -
X-Content-Type-Options: nosniff, an explicitReferrer-Policy, and a deny-by-defaultPermissions-Policy - SRI +
crossorigin="anonymous"on every cross-origin script and stylesheet — or, better, the file self-hosted and pinned - COOP/COEP only if
SharedArrayBuffer, memory measurement or high-resolution timers are actually used, and verified withself.crossOriginIsolated - No
X-XSS-Protection: 1, noExpect-CT, nodocument.domain, noX-Frame-Optionsshipped alone - Headers verified with
curl -sIagainst the deployed origin, not against a local dev server