Skip to content

NEXT.JS & REACT

Next.js 15 Content Security Policy: Strict Nonces in App Router

Implement a strict Content Security Policy in Next.js 15 App Router using cryptographic nonces in middleware, script-src protection, and automated header validation.

A production nextjs content security policy requires generating a per-request cryptographic nonce in middleware and passing it to Server Components before HTML serialization begins. Attempting to define a strict CSP entirely within static configuration files either forces insecure 'unsafe-inline' exceptions or crashes client-side hydration.

By leveraging Next.js 15 App Router middleware to mint Web Crypto nonces, setting the modern 'strict-dynamic' directive, and forwarding the nonce via internal request headers, you can deploy a zero-trust script policy that defeats cross-site scripting (XSS) while preserving framework bundle loading.

Key takeaways

  • Static next.config.mjs headers cannot generate dynamic nonces; middleware is required for per-request cryptographic tokens.
  • Modern CSP Level 3 uses 'strict-dynamic' and 'nonce-...' to authorize Next.js runtime hydration without 'unsafe-inline'.
  • Internal request headers (x-nonce) allow root layouts and Server Components to consume the nonce via headers().
  • Development environments require 'unsafe-eval' to maintain Turbopack and Webpack Fast Refresh functionality.
  • Companion HTTP security headers (nosniff, DENY, strict-origin-when-cross-origin) provide essential defense-in-depth across legacy clients.

Nextjs content security policy requires separating static and dynamic scripts

In standard web applications, developers often start configuring a Content Security Policy by writing a static string inside web server configurations or Next.js headers() inside next.config.mjs. While static rules work cleanly for static media directives like img-src or font-src, they fail completely when applied to modern React frameworks.

Next.js generates multiple inline scripts to serialize page hydration payloads, runtime environment configurations, and initial chunk manifests. If your policy specifies script-src 'self', the browser refuses to execute these inline hydration scripts, leaving your application as static markup with dead event listeners.

The historic workaround was adding 'unsafe-inline' to script-src. Doing so renders the entire Content Security Policy useless against real-world cross-site scripting attacks, as any injected <script> tag or malicious attribute handler executes with identical privileges. The correct architecture separates your policy into two components:

  1. Static perimeter rules: Directives for stylesheets, images, fonts, and frame ancestors that remain constant across all requests.
  2. Dynamic execution tokens: A single-use cryptographic token (nonce) generated on every page hit that validates legitimate application scripts while blocking third-party injection.
CSP DirectiveDevelopment ValueProduction ValueSecurity Purpose
script-src'self' 'unsafe-eval' 'nonce-${nonce}''strict-dynamic' 'nonce-${nonce}' 'self'Authorizes trusted Next.js execution trees
style-src'self' 'unsafe-inline''self' 'unsafe-inline' https://fonts.googleapis.comPermits styled components and Tailwind tokens
font-src'self' data:'self' https://fonts.gstatic.com data:Whitelists web font assets and data URIs
frame-ancestors'none''none'Prevents clickjacking and malicious iframe embedding
object-src'none''none'Blocks legacy plugin and Flash execution
base-uri'self''self'Prevents unauthorized base-tag URL rewriting

Generate cryptographic nonces in Next.js middleware

Because Next.js App Router executes middleware on the Edge or Node.js runtime prior to routing requests to Server Components, middleware serves as the single authoritative point for generating security tokens.

Generating a secure nonce requires high entropy. Using crypto.randomUUID() or Web Crypto crypto.getRandomValues() yields 128 bits of cryptographic randomness. We encode this token as standard base64:

middleware.ts
import { NextResponse, type NextRequest } from "next/server";

export function generateNonce(): string {
  // 16 bytes of entropy encoded as base64
  return Buffer.from(crypto.randomUUID()).toString("base64");
}

export function buildContentSecurityPolicy(nonce: string, isDev: boolean): string {
  const scriptSrc = isDev
    ? `'self' 'unsafe-eval' 'nonce-${nonce}'`
    : `'strict-dynamic' 'nonce-${nonce}' 'self'`;

  return [
    `default-src 'self'`,
    `script-src ${scriptSrc}`,
    `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
    `font-src 'self' https://fonts.gstatic.com data:`,
    `img-src 'self' blob: data: https:`,
    `connect-src 'self' https:`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `object-src 'none'`,
  ].join("; ");
}

To ensure both the incoming request pipeline and the outgoing client response carry the security contract, middleware performs two distinct modifications:

  1. Request Header Mutation: It stamps x-nonce and the computed Content-Security-Policy header into the incoming request headers using request.headers.set(). This makes the values readable by downstream Server Components via next/headers.
  2. Response Header Application: It stamps Content-Security-Policy onto the returned NextResponse instance so the client browser enforces the rules.
middleware.ts
export async function middleware(request: NextRequest) {
  const nonce = generateNonce();
  const isDev = process.env.NODE_ENV !== "production";
  const cspHeaderValue = buildContentSecurityPolicy(nonce, isDev);

  // Clone headers for internal Server Component propagation
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", cspHeaderValue);

  // Pass mutated headers down to the App Router engine
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });

  // Stamp security headers onto client response
  response.headers.set("Content-Security-Policy", cspHeaderValue);
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

  return response;
}

Propagate nonces to Server Components and document layouts

Once middleware attaches the nonce to the internal request headers, root layouts and Server Components consume the token using the asynchronous headers() API in Next.js 15.

In the root layout, reading headers() automatically informs Next.js that the root document tree requires dynamic per-request rendering. This matches our architectural requirement, as each HTML response must deliver an unpredictable nonce:

app/layout.tsx
import { headers } from "next/headers";
import Script from "next/script";
import type { ReactNode } from "react";

export default async function RootLayout({
  children,
}: {
  children: ReactNode;
}) {
  const headerList = await headers();
  const nonce = headerList.get("x-nonce") ?? undefined;

  return (
    <html lang="en">
      <head>
        {/* Pass nonce to Google Analytics or external telemetry scripts */}
        <Script
          strategy="afterInteractive"
          src="https://www.googletagmanager.com/gtag/js?id=G-DEMO"
          nonce={nonce}
        />
      </head>
      <body>
        {children}
      </body>
    </html>
  );
}

When building structured data or custom inline telemetry, inline script elements must explicitly bind this nonce:

components/seo/JsonLd.tsx
import { headers } from "next/headers";

interface JsonLdProps {
  schema: Record<string, unknown>;
}

export async function JsonLd({ schema }: JsonLdProps) {
  const headerList = await headers();
  const nonce = headerList.get("x-nonce") ?? undefined;

  // Sanitize serialized JSON against </script> injection
  const serialized = JSON.stringify(schema).replace(/</g, "\\u003c");

  return (
    <script
      type="application/ld+json"
      nonce={nonce}
      dangerouslySetInnerHTML={{ __html: serialized }}
    />
  );
}

Similar to our timing-safe authentication contracts and Server Actions CSRF defenses, enforcing cryptographic verification on all dynamic inputs stops browser-level attacks before application logic executes.

Configure strict-dynamic to replace unsafe-inline

Prior to Content Security Policy Level 3, maintaining complex Single Page Applications required hashing every compiled chunk or specifying static CDN hostnames. This created fragile deployment pipelines where updating a minor package hash broke production script execution.

The 'strict-dynamic' directive changes trust propagation:

Policy evaluation rule
Browser executes root script with valid nonce
  ↓
Root script dynamically creates <script> element for vendor chunk
  ↓
Browser observes parent script was trusted via nonce
  ↓
Browser automatically executes child chunk without requiring explicit hash

When a modern browser supporting CSP Level 3 observes 'strict-dynamic', it intentionally ignores host allowlists ('self', https:) and disables 'unsafe-inline' fallbacks for script execution.

Directives comparison
Legacy CSP (Level 2):
script-src 'self' 'unsafe-inline' https://cdn.example.com;
↑ Permits any inline XSS payload injected via URL parameters or database values.

Modern CSP (Level 3):
script-src 'strict-dynamic' 'nonce-rAnd0m123' 'self';
↑ Blocks all un-nonced inline scripts and unauthorized external script loads.

Balance local development and production CSP policies

One of the most frequent points of failure when adopting a strict Content Security Policy in Next.js is local developer experience. In development mode, Next.js utilizes Webpack or Turbopack with inline eval() expressions to achieve sub-second Hot Module Replacement (HMR).

If you apply production 'strict-dynamic' without 'unsafe-eval' to localhost:3000, your console immediately fills with EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive.

To prevent developers from disabling CSP locally, branch your policy cleanly based on environment:

lib/csp.ts
export function getScriptSrcDirective(nonce: string): string {
  const isProduction = process.env.NODE_ENV === "production";

  if (isProduction) {
    // Zero eval, zero inline, trust propagated through nonces
    return `'strict-dynamic' 'nonce-${nonce}' 'self'`;
  }

  // Permit evaluation strictly during local development
  return `'self' 'unsafe-eval' 'nonce-${nonce}'`;
}

This ensures developers test their nonce plumbing on every build while reserving hard evaluation boundaries for live infrastructure.

Deploy a complete HTTP security header suite

A Content Security Policy represents the script execution boundary, but robust perimeter defense requires companion headers. Per MDN Web Security Standards and Next.js Security Guidance, every response should ship with the following baseline headers:

lib/security-headers.ts
export const baselineSecurityHeaders = [
  {
    key: "X-Content-Type-Options",
    value: "nosniff",
  },
  {
    key: "X-Frame-Options",
    value: "DENY",
  },
  {
    key: "Referrer-Policy",
    value: "strict-origin-when-cross-origin",
  },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
  },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains; preload",
  },
];

These headers guarantee that:

  • Browsers never guess MIME types for user-uploaded assets (nosniff).
  • Malicious third-party portals cannot wrap your application in invisible <iframe> overlays (DENY).
  • External link transitions truncate sensitive URL path parameters (strict-origin-when-cross-origin), protecting query state like pagination parameters.
  • Unused hardware capabilities like cameras and microphones cannot be commandeered by compromised dependencies (Permissions-Policy).

Best practices for Next.js CSP deployment

  1. Generate nonces using cryptographically secure PRNGs. Never derive nonces from timestamps, static hashes, or sequential counters. Use crypto.randomUUID() or Web Crypto random bytes.
  2. Never allow 'unsafe-inline' in production script-src. If an external analytics or marketing library requires inline execution, pass the nonce to its loader tag rather than relaxing policy boundaries.
  3. Set frame-ancestors 'none' inside CSP. While X-Frame-Options: DENY protects older user agents, modern browsers prioritize frame-ancestors 'none', which provides complete clickjacking protection.
  4. Sanitize inline JSON payloads. When embedding JSON-LD or initial state, always escape < characters as \u003c to eliminate premature script tag termination vulnerabilities.
  5. Monitor policy violations with report-to. Deploy a reporting endpoint using the report-uri or report-to directives to detect broken third-party integrations before enforcing breaking policy changes.

Common Next.js content security policy mistakes

Hardcoding nonces in static page exports

Attempting to run a strict nonce-based CSP on a site built with output: 'export' or static SSG routes fails by definition. Because static HTML files are generated at build time, any embedded nonce is baked into the static artifact and shared across all visitors, turning the nonce into a known static value that attackers can easily bypass.

Forgetting to propagate the nonce via request headers

Generating a nonce inside middleware and stamping it only on the outgoing NextResponse breaks App Router rendering. Server Components run before the response is constructed and have no way to inspect outgoing response headers. You must clone and mutate incoming request headers (request.headers.set("x-nonce", nonce)) so headers() can read it in downstream components.

Whitelisting wildcard domains in connect-src

Setting connect-src * to allow arbitrary API fetches neutralizes data exfiltration defenses. If an attacker discovers an injection flaw, a permissive connect-src allows them to beacon sensitive session tokens or keystrokes to an arbitrary foreign domain without browser interference.

Missing style-src unsafe-inline exemptions

While script execution should never permit 'unsafe-inline', CSS stylesheets frequently require it when using CSS-in-JS libraries or dynamic CSS variable updates. Enforcing nonces on stylesheets often produces noticeable flashes of unstyled content (FOUC). Whitelisting 'unsafe-inline' for style-src is acceptable when script-src remains strictly locked down.

Conclusion: Durable browser-level defense

A production Content Security Policy turns the browser into an active enforcement engine for your application's security model. By binding cryptographic nonces in Next.js 15 App Router middleware, pairing 'strict-dynamic' with modern Web Crypto tokens, and propagating values through internal request headers, you secure client execution without compromising modern React developer workflows.

Review your current route headers, replace legacy 'unsafe-inline' directives with dynamic middleware nonces, and verify your policy with automated CI guard suites to maintain reliable, verifiable defense across every release.

Frequently asked questions

Why can't Content Security Policy be set purely in next.config.mjs?
A strict Content Security Policy requires generating a unique cryptographic nonce on every HTTP request. Because next.config.mjs headers apply statically across the entire deployment at build time, it cannot mint per-request nonces. Middleware runs on every incoming request, making it the proper location to generate nonces and inject request headers.
What does 'strict-dynamic' do in a Next.js CSP?
The 'strict-dynamic' directive instructs modern browsers to allow scripts loaded by an already-authorized script (one carrying the valid cryptographic nonce) to execute. This allows Next.js client-side chunk loading and runtime hydration to function without needing to whitelist individual bundle hashes or resort to 'unsafe-inline'.
How do Server Components access the CSP nonce generated in middleware?
Next.js middleware rewrites the incoming request headers to include an internal header (such as 'x-nonce') holding the generated base64 value. Server Components in the App Router then read this header using the 'headers()' API from 'next/headers' and pass it to inline script tags or external script loaders.
Why does Next.js Fast Refresh break with strict CSP in development?
Next.js local development uses eval() inside Webpack/Turbopack to enable Fast Refresh and source-map evaluation. A strict production CSP forbids 'unsafe-eval', which immediately crashes the development runtime. Isolating your middleware policy to include 'unsafe-eval' only when process.env.NODE_ENV !== 'production' resolves the crash.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.