← Back to Blog
ReactNext.jsCSPContent Security PolicyXSSWeb Security

Content Security Policy in React and Next.js: A Practical Guide to CSP with Nonces and strict-dynamic [2026]

Introduction

Content Security Policy (CSP) is the single most effective defense against Cross-Site Scripting (XSS) in modern web applications — yet it remains one of the most misunderstood and poorly implemented security controls in the React ecosystem.

A 2025 survey of 300 SaaS startups found that only 12% had a deployed CSP, and of those, over 70% used an allowlist-based policy that provided minimal real protection. The problem isn't that developers don't care about security — it's that CSP has a reputation for being painful to implement, easy to break, and hard to debug.

This guide changes that. You'll learn how to implement a production-grade Content Security Policy for React and Next.js applications using the nonce + strict-dynamic pattern — the approach recommended by the W3C and Google's security team for modern single-page applications. We'll cover nonce generation, middleware configuration, reporting, and a complete audit checklist you can use today.

How CSP Actually Works

At its core, CSP is a declarative allowlist that tells the browser what resources are allowed to load and execute on your page. It's delivered via an HTTP response header:

Content-Security-Policy: script-src 'self' https://apis.google.com

This header tells the browser: "Only execute scripts from the same origin and from apis.google.com. Everything else gets blocked."

The magic of CSP is that even if an attacker manages to inject a <script> tag into your page, the browser refuses to execute it unless that script's source matches the policy. For React applications, where most XSS vectors involve injecting script elements or inline event handlers, this is a game-changer.

The Modern CSP Strategy: Nonces

The old approach to CSP was an allowlist of domains:

script-src 'self' https://cdn.example.com https://analytics.example.com

This has two fatal problems:

  1. CDN compromise: If any allowlisted CDN is compromised, your entire CSP is defeated. An attacker can host malicious scripts on the allowlisted domain.
  2. JSONP and gadget attacks: Many CDNs host JavaScript libraries with JSONP endpoints that can be abused to execute arbitrary code.

The modern approach is nonce-based CSP:

script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic'

A nonce is a cryptographically random, single-use token generated by the server for each HTTP response. The server embeds this nonce in every trusted <script> tag:

html
<script nonce="aB3dE9xZ1k">
  // Trusted inline script
</script>
<script src="/app.js" nonce="aB3dE9xZ1k"></script>

The browser only executes scripts that carry the correct nonce. Everything else — including attacker-injected scripts — is blocked, regardless of where they come from.

Nonce-Based CSP in Next.js

Implementing nonce-based CSP in Next.js requires generating a fresh nonce on every request and attaching it to both the CSP header and your rendered HTML.

Step 1: Nonce Generation in Middleware

The cleanest approach is to generate the nonce in Next.js middleware and attach it to the request:

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

// Cryptographically secure nonce generation using Web Crypto API
function generateNonce(): string {
  const buffer = new Uint8Array(32);
  crypto.getRandomValues(buffer);
  return Array.from(buffer)
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

export function middleware(request: NextRequest) {
  const nonce = generateNonce();
  const requestHeaders = new Headers(request.headers);

  // Pass the nonce to server components via request header
  requestHeaders.set('x-nonce', nonce);

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });

  // Build the CSP header
  // Using 'strict-dynamic' means we don't need allowlisted CDN hosts
  const csp = [
    `default-src 'self'`,
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    // Report-URI for violation reporting (more on this later)
    `report-uri /api/csp-report`,
  ].join('; ');

  response.headers.set('Content-Security-Policy', csp);

  return response;
}

export const config = {
  matcher: [
    // Apply to all routes except API routes and static files
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

Step 2: Passing the Nonce to Server Components and Scripts

Next.js Server Components need access to the nonce to add it to rendered <script> tags and inline event handlers:

tsx
// lib/nonce.ts
import { headers } from 'next/headers';

export async function getNonce(): Promise<string> {
  const headersList = await headers();
  return headersList.get('x-nonce') || '';
}

Now, use this in your layout to add the nonce to Next.js scripts:

tsx
// app/layout.tsx
import type { Metadata } from 'next';
import { headers } from 'next/headers';

export const metadata: Metadata = {
  title: 'My App',
  description: 'Secured with CSP',
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce') || '';

  return (
    <html lang="en">
      <head>
        {/* Keep nonce in a meta tag for client components to reference */}
        <meta name="csp-nonce" content={nonce} />
      </head>
      <body>
        {children}
        {/* Next.js critically needs the nonce on its injected scripts */}
        <script
          nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `/* Next.js bootstrap scripts are trusted via nonce */`,
          }}
        />
      </body>
    </html>
  );
}

The Next.js Script Nonce Problem

Here's the critical detail most guides miss: Next.js injects its own scripts at runtime — the Next.js runtime chunks, React itself, and webpack-bundled modules. These scripts don't pass through your middleware-generated nonce automatically.

To make this work, you need to configure Next.js to use the nonce:

ts
// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // ... your other config
  experimental: {
    // In Next.js 16, you can pass a nonce provider function
    // to the App Router's script loader
  },
};

export default nextConfig;

In Next.js 15+, the recommended approach is to modify the next/dynamic loading or use the useReportWebVitals pattern with a custom Script component that accepts the nonce. The simplest production pattern is:

tsx
// components/ScriptWithNonce.tsx
'use client';

import Script from 'next/script';
import { useMemo } from 'react';

export function ScriptWithNonce(
  props: React.ComponentProps<typeof Script>
) {
  const nonce = useMemo(() => {
    const meta = document.querySelector('meta[name="csp-nonce"]');
    return meta?.getAttribute('content') || '';
  }, []);

  return <Script {...props} nonce={nonce} />;
}

For a more robust solution, patch next/script globally by wrapping it in a provider that reads the nonce meta tag:

tsx
// components/NonceProvider.tsx
'use client';

import React, { createContext, useContext } from 'react';

const NonceContext = createContext<string>('');

export function NonceProvider({ children }: { children: React.ReactNode }) {
  // Read the nonce from the meta tag set in the layout
  const nonce =
    typeof document !== 'undefined'
      ? document
          .querySelector('meta[name="csp-nonce"]')
          ?.getAttribute('content') || ''
      : '';

  return (
    <NonceContext.Provider value={nonce}>
      {children}
    </NonceContext.Provider>
  );
}

export function useNonce(): string {
  return useContext(NonceContext);
}

strict-dynamic: Why It Matters

'strict-dynamic' is the CSP directive that makes nonce-based policies practical for modern JavaScript applications. Here's what it does:

When a script with a valid nonce loads another script (e.g., dynamically appending a <script> tag to the DOM), strict-dynamic tells the browser: "Trust the dynamically loaded script too, because it was loaded by a trusted source."

Without strict-dynamic, your CSP would need to explicitly list every possible script source:

script-src 'nonce-aB3dE9xZ1k' 'self' https://cdn.jsdelivr.net https://www.googletagmanager.com

With strict-dynamic, the policy becomes:

script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic'

Your trusted JavaScript bundles can dynamically import or append any script they need — lazy-loaded React components, analytics snippets, third-party widgets — and the browser accepts them because they come from a trusted (nonced) source.

Browser Compatibility

| Browser | strict-dynamic Support | |---------|-------------------------| | Chrome 52+ | [V] Full | | Firefox 52+ | [V] Full | | Safari 15.4+ | [V] Full | | Edge 79+ | [V] Full |

For older browsers that don't support strict-dynamic, include a fallback:

script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic' 'unsafe-inline' https:

Old browsers ignore strict-dynamic and fall back to unsafe-inline (which is less secure but prevents breakage). Modern browsers parse strict-dynamic and ignore the fallback.

Managing eval and Inline Styles

The unsafe-eval Problem

React DevTools, Redux DevTools, webpack hot module replacement, and some third-party SDKs use eval() or new Function() internally. CSP blocks these by default.

You have three options:

  1. Drop CSP on dev builds — only enable it in production:
ts
// middleware.ts
export function middleware(request: NextRequest) {
  if (process.env.NODE_ENV === 'development') {
    return NextResponse.next();
  }
  // ... CSP logic
}
  1. Use unsafe-eval with awareness — acceptable if you audit your eval usage:
script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic' 'unsafe-eval'
  1. Remove eval dependencies — the most secure option. Many libraries have eval-free alternatives:
    • Use lodash instead of underscore (which uses Function constructor)
    • Avoid Redux DevTools in production
    • Use a production build that strips webpack HMR code

The style-src Challenge

React injects inline styles for dynamic CSS (e.g., style={{ color: 'red' }}). To allow this with CSP, you need either:

  1. unsafe-inline in style-src (most common, acceptable for internal apps):
style-src 'self' 'unsafe-inline'
  1. Nonce-based styles:
style-src 'nonce-aB3dE9xZ1k'

Then pass the nonce to all inline <style> tags.

  1. CSS-in-JS with CSP awareness — libraries like styled-components support the nonce attribute:
tsx
import { StyleSheetManager } from 'styled-components';

function App({ nonce }: { nonce: string }) {
  return (
    <StyleSheetManager nonce={nonce}>
      <YourApp />
    </StyleSheetManager>
  );
}

For Tailwind CSS users (v4 with JIT engine), styles are emitted as a single CSS file in production builds, not inline. This means you don't need unsafe-inline for styles in production — Tailwind's generated CSS is loaded via <link> tags, which CSP handles via style-src 'self':

style-src 'self';

CSP for Third-Party Integrations

Third-party scripts are the #1 reason developers break their CSP. Here's how to handle common integrations.

Google Analytics / gtag

Google Analytics recommends two script tags. With nonce-based CSP:

html
<!-- gtag script with nonce -->
<script nonce="aB3dE9xZ1k" async
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX">
</script>
<script nonce="aB3dE9xZ1k">
  window.dataLayer = window.dataLayer || [];
  function gtag(){ dataLayer.push(arguments); }
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXX');
</script>

Because strict-dynamic is set, gtag's dynamically loaded scripts are automatically trusted.

Stripe.js / PayPal / Payment SDKs

Payment SDKs typically load additional scripts at runtime. With strict-dynamic, your nonced payment integration script can dynamically load Stripe.js, and the browser trusts it:

tsx
'use client';

import { useEffect } from 'react';

export function StripeCheckout() {
  useEffect(() => {
    // Stripe dynamically loads additional scripts
    // With strict-dynamic, these are automatically trusted
    const stripe = await loadStripe('pk_test_xxxx');
    // ...
  }, []);

  return <div id="checkout">...</div>;
}

Sentry / Error Tracking

Sentry's SDK loads its transport scripts dynamically. With strict-dynamic, you just need the initial Sentry bundle to have a nonce:

tsx
<Script
  src="/sentry.js"
  nonce={nonce}
  strategy="beforeInteractive"
/>

CSP Reporting

CSP violations happen — third-party scripts change, developers make mistakes, and browser behaviors differ. Never deploy a CSP without a reporting mechanism.

Report-Only Mode

Before enforcing a policy, use Content-Security-Policy-Report-Only mode to test:

Content-Security-Policy-Report-Only: script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic'; report-uri /api/csp-report

The browser reports violations without blocking anything. This is how you discover edge cases before your users hit blocked scripts.

Setting Up a Report Endpoint in Next.js

ts
// app/api/csp-report/route.ts
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const report = await request.json();

    // Log the violation
    console.warn('[CSP Violation]', {
      'blocked-uri': report['csp-report']?.['blocked-uri'],
      'violated-directive': report['csp-report']?.['violated-directive'],
      'source-file': report['csp-report']?.['source-file'],
      'line-number': report['csp-report']?.['line-number'],
      'user-agent': request.headers.get('user-agent'),
      timestamp: new Date().toISOString(),
    });

    // Send to your logging service (Sentry, Datadog, etc.)
    // await logToService('csp-violation', report);

    return Response.json({ status: 'ok' });
  } catch (error) {
    return Response.json({ status: 'error' }, { status: 400 });
  }
}

Using Report-To (Modern Alternative)

The newer report-to directive works with the Reporting API and is more efficient than report-uri:

Content-Security-Policy: script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic'; report-to csp-endpoint
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}

Support is Chrome-only (v69+) and Edge (v79+), so use both report-uri (fallback) and report-to:

Content-Security-Policy: ...; report-uri /api/csp-report; report-to csp-endpoint

A Complete Production Middleware

Here's a production-ready middleware that handles nonce generation, CSP enforcement, and reporting:

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

function generateNonce(): string {
  const buffer = new Uint8Array(32);
  crypto.getRandomValues(buffer);
  return btoa(String.fromCharCode(...buffer))
    .replace(/[/+]/g, '_') // URL-safe base64
    .slice(0, 32);
}

export function middleware(request: NextRequest) {
  // Skip in development for HMR compatibility
  if (process.env.NODE_ENV === 'development') {
    return NextResponse.next();
  }

  const nonce = generateNonce();
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-csp-nonce', nonce);

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });

  // Build a production-grade CSP
  const directives = [
    // Base policy
    `base-uri 'self'`,
    `default-src 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,

    // Scripts: nonce + strict-dynamic
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,

    // Styles: self + unsafe-inline for inline React styles
    `style-src 'self' 'unsafe-inline'`,

    // Images: self + data: URLs for React
    `img-src 'self' data: blob:`,

    // Fonts
    `font-src 'self' data:`,

    // Connections
    `connect-src 'self'`,

    // Workers
    `worker-src 'self' blob:`,

    // Reporting
    `report-uri /api/csp-report`,
  ].join('; ');

  // Set both enforcement and report-only headers
  // Initially: use Report-Only to validate
  // After stabilization: switch to Content-Security-Policy
  response.headers.set(
    'Content-Security-Policy-Report-Only',
    directives
  );

  return response;
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Deployment workflow:

  1. Week 1: Deploy with Content-Security-Policy-Report-Only — monitor violations without breaking anything
  2. Week 2: Review all reported violations, fix false positives
  3. Week 3: Switch to Content-Security-Policy (enforcement mode)
  4. Ongoing: Keep the report endpoint active to catch regressions

Common Pitfalls and Solutions

Pitfall 1: The Extension Problem

Browser extensions can inject their own scripts. These scripts lose their nonce on page refresh because the extension's content script has a different nonce context.

Solution: Use 'strict-dynamic' — when a user's extension injects a script via chrome.scripting.executeScript(), the extension's content script is the loader, and strict-dynamic trusts it. Extensions that use the manifest v3 world: 'MAIN' approach for script injection may still be blocked — this is expected and correct behavior.

Pitfall 2: Hot Module Replacement in Development

HMR injects inline scripts and uses WebSocket connections that fail under CSP.

Solution: Disable CSP entirely in development:

ts
if (process.env.NODE_ENV === 'development') {
  return NextResponse.next();
}

Pitfall 3: next/dynamic and Lazy Loading

Dynamic imports with next/dynamic create new script elements at runtime. Without strict-dynamic, these fail.

Solution: Always include 'strict-dynamic' in your script-src when using dynamic imports.

Pitfall 4: Next.js Draft Mode and Preview

Next.js Draft Mode and Preview Mode use cookies for authentication. These are fine under CSP (cookies are not controlled by CSP), but the preview indicator bar may inject inline styles.

Solution: Allow 'unsafe-inline' for style-src, or pass the nonce to the preview indicator component.

Pitfall 5: Inline Event Handlers in Legacy Code

Event handlers like onclick="handleClick()" or onerror="callback(this)" are blocked by strict CSP. React's synthetic events (JSX onClick={...}) are fine because React attaches them via addEventListener in trusted JavaScript.

Solution: If you have legacy server-rendered HTML with inline event handlers, migrate to addEventListener or wrap the handlers in nonced script tags.

CSP Audit Checklist

Use this checklist when deploying a CSP in your React/Next.js application:

  • [ ] Nonce generated from cryptographically secure random source (Web Crypto API)
  • [ ] Nonce regenerated on every HTTP response (not cached, not per-session)
  • [ ] 'strict-dynamic' included in script-src for dynamic imports
  • [ ] base-uri set to 'self' (prevents base tag injection)
  • [ ] form-action set to 'self' (prevents form-jacking to attacker servers)
  • [ ] frame-ancestors set to 'none' or specific origins (prevents clickjacking)
  • [ ] No 'unsafe-inline' in script-src (use nonces instead)
  • [ ] Eval usage audited and removed where possible
  • [ ] CSP deployed in Report-Only mode for at least one week before enforcement
  • [ ] Report endpoint implemented and monitored
  • [ ] All third-party scripts (GA, Stripe, Sentry) tested under CSP
  • [ ] Development environment excluded from CSP enforcement
  • [ ] CSP header validated with CSP Evaluator
  • [ ] Inline styles managed: style-src 'unsafe-inline' or nonce-based
  • [ ] Workers allowed via worker-src 'self' blob: (for Web Workers)
  • [ ] Connect sources allowlisted for API endpoints (connect-src 'self' https://api.example.com)
  • [ ] Report-To header configured alongside report-uri for Chrome/Edge users

Measuring the Impact

What does a properly implemented CSP actually protect against in a React application?

| Attack Vector | CSP Block? | Why | |--------------|-----------|-----| | Stored XSS via <script> tags | [V] Blocked | Injected script has no nonce | | Reflected XSS via URL parameters | [V] Blocked | Injected script has no nonce | | DOM-based XSS via innerHTML | [V] Blocked | Generated script elements have no nonce | | CSS injection / data exfiltration | [V] Blocked (partial) | style-src blocks malicious <style> tags | | Base tag injection | [V] Blocked | base-uri: 'self' prevents base hijacking | | Form jacking | [V] Blocked | form-action: 'self' keeps forms submitting to your origin | | Clickjacking via iframes | [V] Blocked | frame-ancestors: 'none' prevents embedding | | Prototype pollution → script injection | [V] Blocked | No nonce on attacker-injected scripts | | JavaScript URLs in links | [V] Blocked | javascript: URLs are not trusted scripts | | MIME-type confusion | [V] Blocked (via X-Content-Type-Options) | Send nosniff header alongside CSP |

CSP does not protect against:

  • Stolen nonces via XSS (if the attacker can read the DOM, they can read the nonce)
  • Server-side injection (SQL injection, SSRF, command injection)
  • CSRF (handled by SameSite cookies and CSRF tokens)
  • Authentication bypass (handled by auth middleware)

Conclusion

Content Security Policy is the most impactful security control you can implement for a React application after authentication. A nonce + strict-dynamic CSP blocks the vast majority of XSS attack vectors — including the most common injection patterns that affect React applications — without breaking the dynamic loading patterns that modern frameworks depend on.

The key takeaways:

  1. Use nonces, not allowlists. Nonce-based CSP with strict-dynamic is the only approach that works for modern React applications without listing every CDN and third-party domain.

  2. Deploy in Report-Only mode first. A one-week report-only period surfaces all violations before you enforce the policy, preventing user-facing breakage.

  3. Monitor your reports. CSP is not set-and-forget. New library versions, third-party script changes, and developer errors can introduce violations. Keep your report endpoint active.

  4. Don't let perfect be the enemy of good. A CSP with 'unsafe-eval' and 'unsafe-inline' for styles is still better than no CSP at all. You can tighten it over time as you audit and remove dependencies.

  5. Start today. Implementing CSP in a greenfield project takes an afternoon. Retrofitting it into an existing codebase with years of third-party integrations takes weeks. The sooner you start, the easier it is.

Next week: We'll dive into OWASP Top 10 for Node.js — the ten most critical security risks in server-side JavaScript and how to prevent each one with practical, audit-ready patterns.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.