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:

The two script-src tokens do different jobs, and the browser reads them in a specific order. The nonce is the identity check; 'strict-dynamic' is the propagation rule that decides what a trusted script is allowed to pull in next.

Anatomy of the two script-src tokensThe nonce token gates which script tags execute while strict-dynamic controls how trust flows to scripts they load. 'nonce-AbC123==' 'strict-dynamic' identity gate only tags whose nonce attribute equals this exact value are allowed to run trust propagation a trusted script may load more; host allowlists and 'unsafe-inline' are ignored
The nonce decides which script tags run; 'strict-dynamic' decides what those trusted scripts may load next, overriding host allowlists in supporting browsers.

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');

Where the nonce can and cannot be set

Next.js exposes three surfaces for emitting headers, but only one of them runs code on the request path. The headers() array in next.config.js and the headers block in vercel.json are both resolved at build time into static values baked into the deployment; they never see an incoming request, so any nonce they emit is a single fixed string shared by every visitor — cryptographically useless. middleware.ts is the only surface that executes per request in the Edge runtime, which is why it is the sole place a real nonce can be minted.

Which Next.js header surface can mint a per-request nonceOnly middleware runs on every request, so only middleware can produce a unique nonce; the config files run at build time and cannot. Surface Runs at Per-request nonce? next.config.js build time No vercel.json build time No middleware.ts every request Yes
Both config files resolve at build time and emit one frozen value; only middleware executes per request, so only middleware can mint a unique nonce.

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;
Per-request nonce flow from middleware to browser validation Middleware generates a nonce, sets it on the response CSP header and forwards it to the renderer, which stamps it on script tags that the browser then validates. middleware.ts mint nonce CSP header x-nonce req response header nonce-AbC123 layout reads <Script nonce> browser match nonce? run : block
Middleware mints the nonce, emits it on the response CSP and forwards it to the renderer; the browser runs only scripts whose nonce attribute matches the header.

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.

The two curl results split the failure space cleanly. If the nonce is identical across calls the problem is caching or static optimization; if it differs but scripts are still refused the problem is a mismatch between the header value and the attribute stamped during render. Follow the branch that matches what you observed.

Diagnosing a broken nonce from two curl responsesA decision tree that routes an identical nonce to a caching fix and a mismatched attribute to a render-wiring fix. curl -sI twice same nonce on both responses? yes no HTML is cached / static nonce frozen at build nonce is per-request but scripts still refused Cache-Control: no-store force dynamic render header nonce == attr? fix x-nonce wiring
An identical nonce points to caching or static optimization; a differing nonce with refused scripts points to a header-versus-attribute mismatch in the render path.

Edge Cases, Security Implications & Safe Rollback

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.