Setting CSP with Per-Request Nonces in Next.js Middleware
This guide is part of the Vercel & Next.js Security Header Management reference. A nonce-based Content-Security-Policy is the only robust way to ship a strict, 'unsafe-inline'-free policy in Next.js, because the framework injects its own inline bootstrap scripts. A nonce must be unique per response, so it cannot come from next.config.js or vercel.json — both run at build time with static values. The mechanism that runs on every request is middleware.ts. This page shows how to mint the nonce, attach it to both the request and the response CSP, read it during render, and apply it to your script tags. The directive theory is covered in the CSP nonce generation reference.
Configuration Syntax & Exact Values
The flow has four moving parts: generate a random nonce, write it into the Content-Security-Policy header, forward it to the renderer through a request header, and read it back during render to stamp onto every <Script>.
The header value you emit per request:
Content-Security-Policy: default-src 'self'; script-src 'nonce-AbC123==' 'strict-dynamic'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Annotated breakdown of the script-src tokens:
'nonce-AbC123=='— the base64 nonce, regenerated on every request. Only<script>tags carrying the matchingnonceattribute execute. The literal bytes afternonce-must equal the attribute value exactly.'strict-dynamic'— propagates trust from a nonced script to any scripts it loads, so you do not have to allowlist every CDN host. In'strict-dynamic'mode browsers that support it ignore host allowlists and'unsafe-inline'inscript-src; only nonced/hashed scripts and their descendants run.'self'/'none'— the standard tight defaults for non-script resources;frame-ancestors 'none'blocks framing in place of X-Frame-Options.
The nonce is generated with the Web Crypto API (available in the Edge runtime middleware runs in):
// 16 random bytes, base64-encoded — fresh per request.
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
Server-Side Configuration
middleware.ts
Place middleware.ts at the project root (or src/). It generates the nonce, injects it into the outgoing CSP, and forwards it to the renderer via a custom request header so the layout can read it.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// 1. Mint a fresh nonce for this single request.
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// 2. Build the policy. Newlines are collapsed to single spaces below.
const csp = [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; ');
// 3. Forward the nonce to the renderer on a REQUEST header.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
// 4. Emit the CSP on the RESPONSE so the browser enforces it.
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set('Content-Security-Policy', csp);
return response;
}
// 5. Skip static assets so they are not needlessly processed.
export const config = {
matcher: [
{
source: '/((?!_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
The nonce travels two ways: on the request header x-nonce (so server components can read it) and inside the response Content-Security-Policy (so the browser enforces it). They must carry the same value.
Reading the nonce in an App Router layout
In the App Router, read the forwarded x-nonce request header with headers() and pass it to every <Script>. Reading headers() opts the route into dynamic rendering.
// 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') ?? '';
return (
<html lang="en">
<body>
{children}
<Script
src="https://cdn.example.com/analytics.js"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
);
}
Next.js automatically propagates the nonce to its own framework <script> tags when it detects a nonce in the CSP, so the bootstrap scripts continue to run under a strict policy without manual tagging.
Pages Router (_document) approach
The Pages Router cannot read request headers in _document the way the App Router can. Read the nonce from the response header in getServerSideProps (or _app) and pass it down, or set it on <NextScript nonce={...}> in a custom _document using the value middleware placed on the request. The simplest robust form reads it in _document.getInitialProps:
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
class MyDocument extends Document<{ nonce: string }> {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
// Middleware set x-nonce on the request headers.
const nonce = (ctx.req?.headers['x-nonce'] as string) ?? '';
return { ...initialProps, nonce };
}
render() {
const { nonce } = this.props;
return (
<Html>
<Head nonce={nonce} />
<body>
<Main />
<NextScript nonce={nonce} />
</body>
</Html>
);
}
}
export default MyDocument;
Diagnostic & Verification Steps
The defining property of a correct setup is that the nonce changes on every request. Two successive curls must show two different nonces.
# Fetch the CSP twice; the nonce token must differ between calls.
curl -sI https://your-app.vercel.app/ | grep -i 'content-security-policy'
curl -sI https://your-app.vercel.app/ | grep -i 'content-security-policy'
Expected output (note the different nonce on each line):
content-security-policy: default-src 'self'; script-src 'nonce-Y2YwM2Vk' 'strict-dynamic'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
content-security-policy: default-src 'self'; script-src 'nonce-OWExZmJj' 'strict-dynamic'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
If both lines show the same nonce, the route is being statically cached — the nonce was frozen at build and is no longer per-request, which is a security regression (see edge cases). Confirm the rendered HTML carries the matching attribute:
# The nonce in the header must equal the nonce on the script tags.
curl -s https://your-app.vercel.app/ | grep -o 'nonce="[^"]*"' | head -1
In DevTools, open the Console: any inline or third-party script lacking the current nonce is reported with Refused to execute inline script because it violates the following Content Security Policy directive. A correctly nonced page shows no CSP violations and all framework scripts execute.
Edge Cases, Security Implications & Safe Rollback
- Reading the nonce forces dynamic rendering. Symptom → fix: the nonce is identical across requests and the page was statically optimized → calling
headers()(or otherwise reading the request) opts the route out of static generation, so a CSP nonce inherently makes the route dynamic. This is required: a per-request nonce is incompatible with a cached static response. Accept the dynamic render, or do not use nonces on that route. - Caching defeats the nonce. A CDN or
Cache-Controlrule that caches the HTML will serve one request’s nonce to many users; the CSP then rejects every other user’s scripts. Ensure nonce’d HTML responses are not cached (Cache-Control: no-storeorprivate), and exclude/_next/staticfrom middleware via thematcherso immutable assets stay cacheable. matchermust exclude static assets. Symptom → fix: build assets get processed by middleware or lose their long-lived cache headers → thematcherdid not exclude_next/staticand_next/image. Use the negative-lookaheadsourceshown above so middleware runs only on document routes.'strict-dynamic'browser fallback. Older browsers that do not understand'strict-dynamic'fall back to the rest ofscript-src. With only a nonce and'strict-dynamic'listed, such browsers honor the nonce but ignore'strict-dynamic'; if you need host-based loading in legacy clients, add explicit host sources — modern browsers ignore them under'strict-dynamic', legacy ones use them.- Safe rollback. A broken nonce blanks the page (all scripts refused). To roll back fast, switch the response header to report-only: change
response.headers.set('Content-Security-Policy', csp)toresponse.headers.set('Content-Security-Policy-Report-Only', csp). Violations are then logged but not enforced, restoring the page while you debug. Once the nonce matches everywhere, switch back to the enforcing header.
Frequently Asked Questions
Why can’t I set the CSP nonce in next.config.js or vercel.json?
Both run at build time with static string values and no access to the request, so they would emit one frozen nonce shared by every visitor — defeating the purpose. A nonce must be unique per response, which only middleware can produce. See the comparison of vercel.json vs next.config.js.
Why is the same nonce returned on every request?
The route is being statically cached, so the nonce was frozen at build. Reading the nonce via headers() forces dynamic rendering; if you also cache the HTML at the CDN, you re-freeze it. Set Cache-Control: no-store on nonced HTML responses and confirm the route renders dynamically.
Do I have to tag Next.js framework scripts with the nonce manually?
No. When Next.js detects a nonce in the Content-Security-Policy it propagates that nonce to its own bootstrap script tags automatically. You only need to add nonce={nonce} to your own <Script> components and any third-party tags.
What does ‘strict-dynamic’ do alongside the nonce?
It lets a trusted nonced script load further scripts without each host being allowlisted, and tells supporting browsers to ignore host allowlists and ‘unsafe-inline’ in script-src. Only nonced or hashed scripts and their descendants execute, which is what makes the policy strict.
Conclusion
A per-request nonce in middleware.ts is the correct way to run a strict, 'unsafe-inline'-free CSP in Next.js: mint the nonce with the Web Crypto API, forward it on a request header, emit it on the response CSP, and read it during render to stamp every script. Accept that nonced routes render dynamically and must not be cached. Roll out incrementally: ship the policy as Content-Security-Policy-Report-Only on a preview deploy first, confirm with two curl -sI calls that the nonce changes and that DevTools shows no violations, then switch to the enforcing header in production.