Content Security Policy in the Next.js App Router: Field Notes on Nonces, strict-dynamic, and the Middleware That Made Every Page Dynamic
Headline: A Content Security Policy (CSP) is an HTTP response header that tells the browser which script, style, and connection sources a document is allowed to use, and in the Next.js App Router the only script policy that survives the framework's runtime chunk loading is a per-request nonce combined with 'strict-dynamic' . The cost I did not budget for: generating that nonce inmiddleware.ts and reading it withheaders() opts every matched route out of static rendering. Key takeaways - A CSP nonce is a per-request random token that appears both in the script-src directive and as anonce attribute on every allowed . Because it must never repeat, HTML carrying a nonce cannot be cached - which is exactly why Next.js drops the route to dynamic rendering. - A host allowlist cannot secure a Next.js app. The App Router emits an inline bootstrap payload ( self.__next_f.push(...) ) and then creates further elements at runtime, soscript-src 'self' both fails to allow the inline payload and fails to distinguish framework chunks from any other same-origin file. - 'strict-dynamic' propagates trust from a nonce-allowed script to any script element that script creates programmatically. It is what makes chunk loading work without enumerating chunk URLs. - Keeping 'unsafe-inline' andhttps: inscript-src is a deliberate fallback, not a hole. A browser that understands nonces ignores'unsafe-inline' , and a browser that understands'strict-dynamic' ignores every host expression in the same directive. - Ship Content-Security-Policy-Report-Only first. The enforcing header turns a policy mistake into a blank page; the report-only header turns the same mistake into a log line. What does a Content Security Policy actually stop? A Content Security Policy does not stop injection. It stops execution. If an attacker gets fetch('/api/me') into a comment field that I render with dangerouslySetInnerHTML , the markup still lands in the DOM - but a policy without 'unsafe-inline' means the browser refuses to run it and emits a violation report instead. Four directives paid for themselves in my apps before I touched script-src at all, because none of them require a nonce and none of them break anything: object-src 'none' removes the legacy plugin vector, base-uri 'self' stops an injected tag from silently repointing every relative URL on the page, form-action 'self' stops an injected form from posting credentials to another origin, and frame-ancestors 'none' is the modern replacement for X-Frame-Options . Those four can go in next.config.js under headers() and stay fully cacheable. The expensive directive is script-src . That is the one that requires the nonce. Why does Next.js need a nonce instead of a domain allowlist? A domain allowlist cannot express what the App Router does at runtime. Next.js serializes the React Server Component payload into inline tags on the document, and its client runtime then creates additional script elements to fetch route chunks on demand. script-src 'self' blocks the inline payload outright, and even if it did not, 'self' would happily execute any same-origin URL - including a user-uploaded file served from my own domain. 'strict-dynamic' is the CSP Level 3 keyword that fixes this. It says: any script element created by an already-trusted script inherits that trust. The inline bootstrap gets its trust from the nonce, and every chunk it loads afterwards inherits it. No chunk hashes, no build-time URL enumeration. The counterintuitive part is what 'strict-dynamic' switches off. In a browser that implements it, all host-source expressions in the same directive - 'self' , https: , a literal CDN domain - are ignored. That is why the recommended policy still lists them: they are a graceful degradation path for older browsers, not additional permission for modern ones. How do I generate and propagate a CSP nonce in the App Router? The nonce is generated once per request in middleware.ts , written to both the request headers and the response headers, and read back in a Server Component with headers() . Writing it onto the request matters: Next.js looks for a content-security-policy request header, extracts the nonce from it, and applies that nonce to the script tags it emits itself. Skip that step and the framework's own bootstrap is blocked by my own policy. // middleware.ts import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const dev = process.env.NODE_ENV !== 'production'; const csp = [ default-src 'self', script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'${dev ? " 'unsafe-eval'" : ''}, style-src 'self' 'unsafe-inline', img-src 'self' blob: data:, connect-src 'self', object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none', upgrade-insecure-requests, ].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; } Reading it in the root layout is two lines. headers() returns a promise in Next.js 15 and later, so it must be awaited, and next/script forwards a nonce prop straight onto the emitted tag. // app/layout.tsx import { headers } from 'next/headers'; import Script from 'next/script'; export default async function RootLayout({ children }: { children: React.ReactNode }) { const nonce = (await headers()).get('x-nonce') ?? undefined; return ( {children} ); } Why did my static pages turn dynamic after I added CSP? Because a nonce is only a security control while it is unpredictable, and a cached HTML response hands the same nonce to every visitor. A reused nonce is functionally identical to 'unsafe-inline' : an attacker who can read one page's markup learns the token that unlocks script execution on every other copy of it. Next.js enforces the safe interpretation by treating headers() as a dynamic API - the moment my root layout calls it, every route under that layout renders per request. That is a real bill. Marketing pages that were prerendered at build time became server-rendered on every hit, and Partial Prerendering could no longer treat the shell as static. | Strategy | Script safety | Rendering cost | |---|---|---| Nonce + 'strict-dynamic' everywhere | Strongest - no inline injection executes | Every matched route renders dynamically | | Nonce scoped to authenticated routes, static policy elsewhere | Strong where user input is rendered | Marketing and docs pages stay static | No nonce, script-src 'self' 'unsafe-inline' | Weak - injected inline script still runs | Fully static | I landed on the middle row for content-heavy sites and the top row for anything behind a login. Scoping is done through the middleware matcher , and Next.js's documented example additionally excludes prefetch requests so a prefetched RSC payload does not burn a nonce it will never use. 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' }, ], }, ], }; What breaks in development and with CSS-in-JS? Development needs 'unsafe-eval' in script-src . React Fast Refresh and eval-based source maps both compile strings at runtime, so a production-grade policy gives me a console full of EvalError the moment I run the dev server. Gate it on process.env.NODE_ENV rather than shipping it everywhere. style-src is where I stopped fighting. Next.js inlines critical CSS as elements, and CSS-in-JS libraries inject more at runtime, frequently from code paths that never see my nonce. I keep 'unsafe-inline' in style-src deliberately: style injection is a far weaker vector than script injection, and the alternative is a policy that breaks on every dependency upgrade. If a threat model demands a style nonce, styled-components reads it from the webpack_nonce global and Emotion accepts one via createCache({ nonce }) . Three more that caught me: next/image blur placeholders are data: URLs, so img-src needs data: and usually blob: ; analytics beacons need their host in connect-src , not script-src , because the script is loaded but the beacon is a separate fetch; and embedded Stripe or YouTube iframes need frame-src , which does not inherit from default-src once you start listing directives explicitly. How should I roll out CSP without breaking production? Send the identical policy under Content-Security-Policy-Report-Only first and leave it there for a full traffic cycle - at minimum a week, so weekday and weekend behaviour both show up. The report-only header instructs the browser to evaluate the policy and report violations without blocking anything, which converts an outage into a log stream. Collecting the reports takes one Route Handler. The legacy report-uri directive posts a single JSON object with content type application/csp-report ; the newer Reporting API uses a Reporting-Endpoints response header plus a report-to directive and posts batched arrays as application/reports+json . Browsers are split across both, so I send both directives and normalise on arrival. // app/api/csp-report/route.ts export async function POST(request: Request) { const body = await request.json(); const reports = Array.isArray(body) ? body : [body]; for (const report of reports) { const blocked = report.body?.blockedURL ?? report['csp-report']?.['blocked-uri'] ?? ''; if (blocked.startsWith('chrome-extension:') || blocked.startsWith('moz-extension:')) continue; console.warn('[csp]', JSON.stringify(report)); } return new Response(null, { status: 204 }); } Filter browser extensions before you alert on anything. The first day I collected reports, the overwhelming majority came from chrome-extension: a
Comments
No comments yet. Start the discussion.