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:
- CDN compromise: If any allowlisted CDN is compromised, your entire CSP is defeated. An attacker can host malicious scripts on the allowlisted domain.
- 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:
<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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
- Drop CSP on dev builds — only enable it in production:
// middleware.ts
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV === 'development') {
return NextResponse.next();
}
// ... CSP logic
}
- Use
unsafe-evalwith awareness — acceptable if you audit your eval usage:
script-src 'nonce-aB3dE9xZ1k' 'strict-dynamic' 'unsafe-eval'
- Remove eval dependencies — the most secure option. Many libraries have eval-free alternatives:
- Use
lodashinstead ofunderscore(which usesFunctionconstructor) - Avoid Redux DevTools in production
- Use a production build that strips webpack HMR code
- Use
The style-src Challenge
React injects inline styles for dynamic CSS (e.g., style={{ color: 'red' }}). To allow this with CSP, you need either:
unsafe-inlineinstyle-src(most common, acceptable for internal apps):
style-src 'self' 'unsafe-inline'
- Nonce-based styles:
style-src 'nonce-aB3dE9xZ1k'
Then pass the nonce to all inline <style> tags.
- CSS-in-JS with CSP awareness — libraries like styled-components support the nonce attribute:
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:
<!-- 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:
'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:
<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
// 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:
// 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:
- Week 1: Deploy with
Content-Security-Policy-Report-Only— monitor violations without breaking anything - Week 2: Review all reported violations, fix false positives
- Week 3: Switch to
Content-Security-Policy(enforcement mode) - 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:
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 inscript-srcfor dynamic imports - [ ]
base-uriset to'self'(prevents base tag injection) - [ ]
form-actionset to'self'(prevents form-jacking to attacker servers) - [ ]
frame-ancestorsset to'none'or specific origins (prevents clickjacking) - [ ] No
'unsafe-inline'inscript-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:
-
Use nonces, not allowlists. Nonce-based CSP with
strict-dynamicis the only approach that works for modern React applications without listing every CDN and third-party domain. -
Deploy in Report-Only mode first. A one-week report-only period surfaces all violations before you enforce the policy, preventing user-facing breakage.
-
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.
-
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. -
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.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.