How I Added a Strict Content Security Policy With Nonces in Next.js
A practical walkthrough of nonce-based CSP in the Next.js App Router: the proxy setup, third-party scripts, the dynamic rendering cost, and a safe rollout.
Jason
6 min read·
For a long time my Next.js apps shipped without a Content Security Policy. I told myself React escapes everything, so XSS wasn't really my problem. Then I audited a side project and found a dangerouslySetInnerHTML rendering Markdown from user input, plus a third-party widget I'd pasted in months earlier. One sanitizer bug away from someone else's JavaScript running on my domain.
A CSP is the safety net for exactly that situation. It tells the browser which scripts are allowed to run, so even if an attacker manages to inject a <script> tag, the browser refuses to execute it. In this post I'll walk through how I added a nonce-based, strict CSP to a Next.js App Router project, the tradeoffs I didn't expect, and how I rolled it out without breaking production.
Why an allowlist CSP isn't enough
The classic CSP looks like script-src 'self' https://cdn.example.com: a list of trusted origins. The problem is that allowlists are leaky. If any allowed origin hosts a JSONP endpoint or an old copy of a library with a known gadget, an attacker can load it and bypass your policy. And the moment you add 'unsafe-inline' to make Next.js's inline bootstrap scripts work, the policy stops protecting against injected inline scripts at all.
A strict CSP flips the model. Instead of trusting origins, you trust individual script tags that carry a secret value, a nonce, which is random and different on every response. An attacker who injects markup can't guess it, so their script is blocked. Adding 'strict-dynamic' then lets scripts you trusted load further scripts, which is what bundlers and tag managers need.
How Next.js handles nonces
The nice surprise is how little I had to wire up manually. Per the official CSP guide, the flow for a dynamically rendered page is:
- Your proxy generates a nonce, puts it into a
Content-Security-Policyheader, and also exposes it as a customx-noncerequest header. - During server rendering, Next.js parses the CSP header from the request and extracts the value from the
'nonce-...'pattern. - Next.js automatically attaches that nonce to its framework scripts, page bundles, the inline scripts and styles it generates, and any
<Script>you pass anonceprop to.
One naming note: in Next.js 16, the middleware.ts file convention was renamed to proxy.ts and the exported function is now proxy. If you're on an older version, the same code works in middleware.ts with a middleware export.
The proxy
Here's the version I ended up with. It's close to the official example, with two additions: a connect-src directive and an environment flag to ship the policy in report-only mode first.
// proxy.ts (named middleware.ts before Next.js 16)
import { NextRequest, NextResponse } from 'next/server'
const REPORT_ONLY = process.env.CSP_REPORT_ONLY === 'true'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`};
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, ' ')
.trim()
const headerName = REPORT_ONLY
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy'
// Next.js reads the nonce from the *request* CSP header while rendering
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(headerName, 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' },
],
},
],
}
A few details worth calling out:
- The nonce goes on the request and the response. The request header is what Next.js reads while rendering; the response header is what the browser enforces. Forget the request side and Next.js won't stamp your scripts, so everything gets blocked.
crypto.randomUUID()is available globally in both the Node.js and Edge runtimes, so there's nothing to import. A UUID gives 122 random bits, which is plenty for a per-response nonce.'unsafe-eval'only in development. React usesevalin dev to reconstruct server error stacks in the browser. Neither React nor Next.js need it in production.- The matcher skips static assets and prefetches. Files under
_next/staticdon't need a header, andnext/linkprefetch requests don't need a fresh nonce. object-src 'none'andbase-uri 'self'close two bypasses that strict CSP guides consistently recommend: plugin content and an injected<base>tag that rewrites relative script URLs.
Passing the nonce to third-party scripts
Next.js handles its own scripts, but anything you add yourself needs the nonce explicitly. In a Server Component you read it from the request headers:
// 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 (
<html lang="en">
<body>
{children}
<Script
src="https://plausible.io/js/script.js"
data-domain="example.com"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
)
}
Because of 'strict-dynamic', I don't need to add the analytics origin to script-src. The nonce'd loader is trusted, and anything it injects inherits that trust. If the script sends data back with fetch or beacons, though, its origin still has to go into connect-src; 'strict-dynamic' only covers script loading.
The catch: every page becomes dynamic
This is the tradeoff that I didn't appreciate until I ran next build. A nonce has to be unique per response, and a static page is generated once at build time when there is no request. So nonce-based CSP requires dynamic rendering. The docs are explicit about the consequences:
- Static optimization and ISR no longer help these pages.
- Pages can't be cached at the CDN without extra configuration.
- Partial Prerendering is incompatible, because the static shell's scripts can't know the nonce.
Reading headers() in the root layout already opts the tree into dynamic rendering. For pages that would otherwise be prerendered, you can be explicit with await connection() from next/server.
For a dashboard or anything behind auth, this costs basically nothing, since those pages were dynamic anyway. For a mostly static marketing site or blog, it's a real performance hit, and I'd look at the alternative below instead.
The alternative: hashes via Subresource Integrity
Next.js has experimental support for Subresource Integrity in the App Router. With experimental.sri set in next.config.js (algorithm sha256, sha384 or sha512), it computes hashes of your JavaScript files at build time and adds integrity attributes to script tags. That lets you keep a CSP without 'unsafe-inline' while still generating pages statically and caching them at the edge.
It's experimental and only covers build-time scripts, so I treat it as the option for static-heavy sites and stick with nonces for apps that render per request anyway. If neither fits, a header in next.config.js with 'unsafe-inline' is still far better than no CSP: you keep object-src, base-uri, frame-ancestors and form-action protections even if inline scripts aren't locked down.
Rolling it out without breaking production
The first time I enforced a strict CSP, a chat widget and an inline style from a component library silently broke. Now I always roll out in two phases:
- Report-only. Set
CSP_REPORT_ONLY=trueso the proxy sendsContent-Security-Policy-Report-Only. The browser evaluates the policy and logs violations to the console (and to a reporting endpoint if you configurereport-to), but blocks nothing. I click through every major flow and watch DevTools. - Enforce. Once the console is clean for a few days of real traffic, flip the flag and the same policy is sent as
Content-Security-Policy.
A quick sanity check from the terminal:
curl -sI https://localhost:3000/ | grep -i content-security-policy
# content-security-policy: default-src 'self'; script-src 'self' 'nonce-ZmY0...' 'strict-dynamic'; ...
# Run it twice: the nonce must be different on every response
If the nonce is identical across requests, something is caching your HTML, and the CSP is effectively a static secret that an attacker can read from any cached page. That's the bug to fix before anything else.
Violations I actually hit
- Inline
styleattributes from a UI library. A nonce applies to<style>elements, not tostyle="..."attributes. I moved the offending styles into CSS modules. Some teams accept'unsafe-inline'forstyle-srconly, since style injection is far less dangerous than script injection. - A Script tag without the nonce prop. Easy to miss in a nested component. Reading the nonce once in the layout and passing it down fixed it.
- WebAssembly. A WASM-based image library needed
'wasm-unsafe-eval'inscript-src, which is much narrower than full'unsafe-eval'.
Key takeaways
- A strict CSP (nonce +
'strict-dynamic') blocks injected scripts even when your sanitizer fails; origin allowlists and'unsafe-inline'mostly don't. - In Next.js, generate the nonce in
proxy.tsand set the CSP on both the request and the response; Next.js stamps its own scripts automatically. - Pass
nonceexplicitly to your own<Script>tags, reading it withheaders()in a Server Component. - Nonces force dynamic rendering, so no ISR, CDN caching or PPR for those pages. For static sites, consider experimental SRI or a non-nonce header.
- Ship in
Content-Security-Policy-Report-Onlymode first, fix the violations, then enforce.
Written by Jason
Published October 8, 2026 · Updated Oct 9, 2026