A Content Security Policy nonce is a random value your server writes into both the CSP header and the nonce attribute of every script it trusts, regenerated on every response. That regeneration is the catch. A nonce means the HTML cannot be cached, so a site that builds to static files should reach for hashes instead.
Both routes land on the same policy shape, the one web.dev calls a strict CSP:
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Three directives, and each earns its place. object-src 'none' blocks plugin content. base-uri 'none' stops an injected <base> element repointing every relative script URL at somebody else’s host, which is the hole a domain allowlist leaves open. Swap the nonce for 'sha256-...' and the same policy covers a static build.
Does a Content Security Policy nonce break caching?
Yes, and that answer drives every other decision here.
MDN is blunt about the requirement: the nonce “must be different for every HTTP response, and must not be predictable”. A value that differs per response cannot sit in a file on disk or in a CDN cache. Next.js says so out loud in its own guidance: you “must use dynamic rendering to add nonces”, because the nonce is applied during server-side rendering by parsing the 'nonce-{value}' pattern out of the request’s CSP header. Static optimisation and ISR are off, Partial Prerendering is incompatible, and every request re-renders. If you have put work into the use cache directive, a nonce-based CSP hands most of it back.
Generating one is short. In Next.js 16 the file is proxy.ts, since middleware.ts was deprecated and renamed in v16.0.0 (there is a codemod, npx @next/codemod@canary middleware-to-proxy .).
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`object-src 'none'`,
`base-uri 'none'`,
`frame-ancestors 'none'`,
].join('; ')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy', csp)
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' },
],
},
],
}
The header is set twice on purpose: on the request so the renderer can find the nonce, on the response so the browser enforces the policy. Next then attaches that nonce to its own framework scripts and page bundles without you editing a single tag. For a third-party embed, read it back in a Server Component with (await headers()).get('x-nonce') and pass it to <Script nonce={nonce}>. In development you will also need 'unsafe-eval', because React uses eval to rebuild server-side stack traces in the browser. Production does not need it.
The missing clause in that matcher matters more than it looks. Prefetches from next/link do not need the header, and skipping them keeps the proxy off a large slice of your traffic.
When are hashes the better choice?
When the HTML is a build artefact. A hash covers the exact bytes of one inline script, so it stays valid until that script changes, which means the page can sit in a CDN edge cache for as long as you like.
Astro does this for you. CSP support went stable in Astro 6.0 on 10 March 2026, and the minimum viable form is security: { csp: true }. The useful form is a little longer:
// astro.config.mjs
import { defineConfig } from 'astro/config'
export default defineConfig({
security: {
csp: {
algorithm: 'SHA-256',
directives: ["default-src 'self'", "img-src 'self' data:"],
scriptDirective: {
strictDynamic: true,
resources: ["'self'"],
},
},
},
})
At build time Astro hashes every inline script and style in each page and emits a <meta http-equiv="content-security-policy"> element into that page’s <head>. Four limits are worth knowing before you switch it on. External scripts and styles are not covered out of the box, so third-party origins go in resources by hand. The <ClientRouter /> view transitions component is not supported. Shiki syntax highlighting is not supported. And the feature is inert in astro dev, so you test with astro build then astro preview, which is exactly the moment a broken policy is easiest to miss.
One quiet trap: if 'unsafe-inline' appears in a directive, Astro stops emitting hashes on it. The keyword looks harmless in a config file and silently disables the whole mechanism.
Hashes also survive a mixed page. A shell that is prerendered keeps its build-time hashes while a personalised fragment arrives on its own request, which is why Astro’s server islands and a hash-based policy get along.
Next.js has a hash-flavoured route too, though a narrower one. experimental.sri (added in v14.0.0, App Router only) generates Subresource Integrity hashes for your JavaScript at build time and writes them as integrity attributes, letting a static page keep a policy with no 'unsafe-inline' and no per-request work.
What does ‘strict-dynamic’ silently switch off?
Your allowlist. This is the single most common way a strict CSP surprises people.
Per MDN, when 'strict-dynamic' is present, “any allowlist or source expressions such as 'self' or 'unsafe-inline' will be ignored”. Trust flows from the nonced or hashed root script to whatever it loads at runtime, and the domain list you carefully assembled stops meaning anything. Adding https://cdn.example.com to script-src next to 'strict-dynamic' does nothing at all. If a third-party script is injected by a tag manager that itself carries the nonce, it runs; if it arrives as a plain <script src> in your markup with no nonce, it is refused, and you get the console line everybody eventually pastes into a search box:
Refused to execute inline script because it violates the following Content Security Policy directive
The related rule is that a nonce or hash in a directive causes unsafe-inline to be ignored by browsers. That is deliberate, and it is how the backwards-compatible policy script-src 'unsafe-inline' https: 'nonce-abc' 'strict-dynamic' degrades cleanly across three generations of CSP support. Old browsers see 'unsafe-inline' https:, current ones see only the nonce and 'strict-dynamic'.
Header or meta tag?
Header, if you have the choice. The CSP specification drops three directives when the policy arrives in a <meta> element: “Neither are the report-uri, frame-ancestors, and sandbox directives.” The report-only variant is not supported there either, in the spec’s own words.
So a meta-delivered policy cannot do clickjacking protection and cannot be trialled in report-only mode. Keep frame-ancestors in a header alongside it. On a static host that usually means a flat header file, which is the same place your other security headers live; if you are weighing that up on Cloudflare, our comparison of Pages and Workers covers where each one applies headers.
How do you roll it out without breaking the site?
Ship it as Content-Security-Policy-Report-Only first, with a real reporting endpoint, and read the violations for a week before you enforce anything.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
The report-to directive names an endpoint declared in the Reporting-Endpoints header rather than carrying a URL itself. Reports arrive as application/reports+json. That header reached Baseline in September 2024 and replaces the older Report-To, which is deprecated; report-uri is deprecated too, though keeping both for a while costs nothing.
Expect noise. Browser extensions inject scripts into your pages and those violations land in your endpoint looking exactly like real ones. Filter by blocked-uri before you conclude your own policy is wrong.
Once enforcement is on and quiet, the next rung is Trusted Types: require-trusted-types-for 'script' makes DOM sinks such as innerHTML reject plain strings and accept only values minted by a registered policy. It reached Baseline in February 2026, so as of August 2026 it is a realistic target rather than a Chromium-only experiment, but it is a code change rather than a header change and it deserves its own piece of work.
What we would actually do
For a server-rendered app that is already dynamic per request, nonces, generated in the proxy or middleware layer, with 'strict-dynamic' and nothing else in script-src. For anything that builds to static HTML, hashes, generated by the framework, because a nonce would cost you the CDN cache that made the site fast in the first place. Hybrid renderers need both, per route.
And sometimes the honest answer is not yet. The _headers file on this site carries a comment explaining that it deliberately omits a CSP: the pages inline critical CSS and a small script, and embed Cloudflare Turnstile, so a policy would need hashes and an allowlist that a flat header file cannot generate. The other headers, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy and HSTS, need no per-resource work and went on immediately. A half-built CSP that you loosen with 'unsafe-inline' every time something breaks protects nobody. Do the cheap headers today, and treat the CSP as a piece of work with a report-only phase in it.