Web Security

CSP for React and Next.js: What Actually Works

Practical guide to implementing Content Security Policy in React and Next.js. Covers nonce strategies, strict-dynamic, CSS in JS fixes, and production configs that won't break your build.

SiteSecurityScore Team·14 min read·Updated Mar 1, 2026
Code editor showing JavaScript source code representing React and Next.js development

You just shipped a React app. Everything works. The tests pass, the CI is green, users are signing up. Then someone from security runs a scan and drops a report on your desk: "No Content Security Policy detected." So you Google around, find a single line CSP example, paste it into your server config, and your entire app goes white. Nothing loads. The console is a wall of red violations.

Sound familiar? You are not alone. CSP is one of those security features that sounds simple in theory but collides violently with how modern JavaScript frameworks actually work. React and Next.js apps are especially tricky because the build tooling injects inline scripts, CSS in JS libraries need runtime style injection, and third party analytics want to eval things that CSP was specifically designed to prevent.

This guide is about what actually works in production. Not the textbook examples, not the "just add 'self' and you're done" advice. The real stuff. The problems you will hit with Create React App, with Next.js, with styled components and Emotion and Tailwind. And the specific solutions that let you ship a strong CSP without breaking your app.

Why CSP is particularly painful with React#

Let's start with the fundamental problem. A strict Content Security Policy says "do not run any inline JavaScript." That is the whole point. Inline script injection is how XSS works, so blocking it is the single most important thing CSP does. But React's build process, by default, injects inline scripts into your HTML.

When you run npm run build with Create React App, it generates an index.html that contains small inline runtime chunks. Webpack does this for performance reasons. It is faster to inline a tiny script than to make a separate network request for it. Great for performance. Terrible for CSP.

Next.js has the same challenge, just in different places. It injects inline scripts for route preloading, data hydration, and the __NEXT_DATA__ JSON blob. Every page load includes inline JavaScript that your CSP needs to account for.

Then there is the styling layer. If you use styled components, Emotion, or any CSS in JS library, those tools inject <style> tags into the DOM at runtime. That is inline CSS. A CSP that blocks unsafe-inline for styles will make your beautifully styled components render as plain, unstyled HTML. Not a great look for production.

The tempting trap

Many teams see the CSP violations and just add 'unsafe-inline' 'unsafe-eval' to make everything work. This effectively disables CSP protection entirely. You have the header, but it is doing nothing. If your CSP includes both of these keywords, an attacker can still inject and execute arbitrary JavaScript on your page.

The three strategies that actually work#

After spending far too much time debugging blank React pages and unstyled Next.js apps, the community has settled on three real strategies for deploying CSP with modern frameworks. Each has tradeoffs, and which one you pick depends on your deployment setup.

Strategy 1: Nonces (the gold standard)

A nonce is a random token generated on every single request. Your server creates a cryptographically random string, attaches it to every inline script and style tag, and includes the same value in the CSP header. The browser checks that each inline element's nonce matches the header. If it does, the code runs. If it does not (because an attacker injected it), the browser blocks it.

This is the strongest approach because even if an attacker finds an injection point, they cannot guess the nonce. It changes on every page load. The downside is that you need a server to generate the nonce and inject it into the HTML. Static hosting like a CDN or S3 bucket cannot do this natively.

Next.js has built in nonce support starting with version 13.4. You generate the nonce in middleware, pass it to your root layout, and Next.js automatically applies it to all the inline scripts it generates. Here is what that looks like.

middleware.ts
import { NextResponse } from 'next/server'; export function middleware(request) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'nonce-${nonce}'`, "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self' https://your-api.com", "frame-ancestors 'none'", "object-src 'none'", "base-uri 'self'", ].join('; '); const response = NextResponse.next(); response.headers.set('Content-Security-Policy', csp); response.headers.set('x-nonce', nonce); return response; }
app/layout.tsx
import { headers } from 'next/headers'; export default async function RootLayout({ children }) { const nonce = (await headers()).get('x-nonce') ?? ''; return ( <html lang="en"> <head> <meta property="csp-nonce" content={nonce} /> </head> <body>{children}</body> </html> ); }

With this setup, Next.js propagates the nonce to all of its internal inline scripts automatically. You get real CSP protection without having to manually tag every script. The 'strict-dynamic' keyword is the secret sauce here. It tells the browser "trust scripts loaded by already trusted scripts." So your nonced entry point can dynamically load additional chunks, and those chunks inherit the trust. This is critical for code split React apps where dozens of chunk files load on demand.

Strategy 2: Hashes (for static builds)

If you are deploying to static hosting (Vercel's static export, Netlify, CloudFront, or plain S3), you probably do not have a server generating nonces on each request. Hashes are your best option here.

The idea is straightforward: compute the SHA-256 hash of each inline script in your built HTML, then include those hashes in your CSP header. The browser computes the same hash at runtime and allows the script if it matches. Since your built files are static and do not change between deploys, the hashes remain stable until your next build.

For Create React App, the first thing you should do is set the environment variable INLINE_RUNTIME_CHUNK=false in your build. This tells webpack to emit the runtime as a separate file instead of inlining it. That alone eliminates the biggest CSP headache with CRA.

.env.production
# Prevents CRA from inlining the webpack runtime chunk INLINE_RUNTIME_CHUNK=false # Your CSP as a meta tag (for static hosting) # Add this to public/index.html <head>: # <meta http-equiv="Content-Security-Policy" # content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" # >

If you still have inline scripts after the build (some CRA versions include a small chunk regardless), you can hash them. Open your built index.html, find the inline <script> tags, and compute their hashes.

Computing a hash from the terminal
# Extract the inline script content and compute its SHA-256 hash echo -n 'your inline script content here' | openssl dgst -sha256 -binary | openssl base64 # Then add it to your CSP: # script-src 'self' 'sha256-BASE64HASHHERE='

The catch with hashes is that they break whenever the script content changes. A new build with different code means new hashes. If you automate your builds (and you should), add a post build step that extracts inline scripts, computes their hashes, and updates your CSP configuration. Some teams use custom webpack plugins or build scripts for this.

Strategy 3: Eliminate inline scripts entirely

This is the simplest approach if you can pull it off. Configure your build to emit zero inline scripts and zero inline styles. Then your CSP is clean and you do not need nonces or hashes for the script directive.

For Create React App, that means setting INLINE_RUNTIME_CHUNK=false and making sure you are not using any <script> tags with inline content in your public/index.html. For Next.js, this is harder because the framework injects inline scripts by design. But with the App Router and proper nonce configuration, you can effectively get the same level of protection.

If you use Tailwind CSS (which compiles to static CSS files), you are already in good shape on the style side. No inline styles, no style injection, no CSP issues. If you are using CSS modules, same story. The problems only come from runtime CSS in JS libraries.

The CSS in JS problem (and how to solve it)#

Here is where things get interesting. Libraries like styled components and Emotion create <style> tags dynamically and inject them into the page. This is inline CSS, and your CSP's style-src directive will block it unless you allow it explicitly.

The easy but bad solution is style-src 'unsafe-inline'. This works but it means an attacker who finds an injection point can insert arbitrary CSS, which enables data exfiltration through CSS selectors and background image URLs. It is not as dangerous as unsafe-inline for scripts, but it is still a real attack vector.

The right solution is nonces for styles. Both styled components (v6+) and Emotion support a nonce prop that gets attached to every injected style tag.

styled components with nonce
import { StyleSheetManager } from 'styled-components'; function App({ nonce }) { return ( <StyleSheetManager nonce={nonce}> <YourApp /> </StyleSheetManager> ); }
Emotion with nonce
import createCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; const emotionCache = createCache({ key: 'css', nonce: window.__CSP_NONCE__, // injected by your server }); function App() { return ( <CacheProvider value={emotionCache}> <YourApp /> </CacheProvider> ); }

With Material UI (which uses Emotion under the hood), you pass the nonce through Emotion's cache and MUI picks it up automatically. The pattern is the same: generate a nonce server side, pass it to your client code, and configure your styling library to use it. Every style tag it injects will carry the nonce, and your CSP will allow it while blocking any attacker injected styles.

A complete Next.js CSP setup#

Let's put it all together for a Next.js App Router project. This is the configuration pattern we see working reliably in production across many sites.

The pieces you need

  • Middleware to generate a nonce and set the CSP header on every request
  • Root layout that reads the nonce and passes it down via meta tag or context
  • next.config.js with any additional headers for static assets
  • CSS in JS config (if applicable) to pass the nonce to your styling library

The middleware generates a fresh nonce for every request. The 'strict-dynamic' keyword means you do not need to list every CDN or chunk URL in your CSP. Any script loaded by a trusted (nonced) script is automatically trusted. This is essential for React apps where code splitting produces dozens of dynamically loaded chunks.

next.config.js
/** @type {import('next').NextConfig} */ const nextConfig = { // These headers apply to static assets served directly async headers() { return [ { source: '/(.*)', headers: [ { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, ], }, ]; }, }; module.exports = nextConfig;

Notice that the CSP header itself is not in next.config.js. It is in the middleware because that is the only place you can generate a fresh nonce per request. The other security headers (X-Frame-Options, X-Content-Type-Options, etc.) can live in the static config because they do not change between requests.

Create React App: the practical approach#

CRA is trickier than Next.js because the default build inlines a webpack runtime chunk. You have a few options, and the right one depends on how you deploy.

If you serve through a reverse proxy (Nginx, Express, Caddy): disable the inline chunk, use your server to generate nonces, and inject them into the HTML before serving. This gives you the strongest protection.

Express server serving CRA build with nonces
import express from 'express'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; const app = express(); const buildPath = path.join(process.cwd(), 'build'); // Read the built index.html once const htmlTemplate = fs.readFileSync( path.join(buildPath, 'index.html'), 'utf8' ); // Serve static assets normally app.use(express.static(buildPath, { index: false })); // For all routes, inject a fresh nonce app.get('*', (req, res) => { const nonce = crypto.randomBytes(16).toString('base64'); const csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}'`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self' https://your-api.com", "frame-ancestors 'none'", "object-src 'none'", "base-uri 'self'", ].join('; '); res.set('Content-Security-Policy', csp); // Inject nonce into script tags const html = htmlTemplate.replace( /<script/g, `<script nonce="${nonce}"` ); res.send(html); }); app.listen(3000);

If you deploy to static hosting (S3, Netlify, Vercel static): you cannot generate nonces server side. Use INLINE_RUNTIME_CHUNK=false in your build, make sure you have no inline scripts, and use a hash based CSP or a meta tag CSP. This is weaker than nonces but still far better than no CSP at all.

Static hosting CSP via meta tag
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://your-api.com; object-src 'none'; base-uri 'self';" >

Meta tag limitation

CSP delivered via a <meta> tag does not support frame-ancestors or report-uri. These directives only work when the CSP is sent as an HTTP header. If you need clickjacking protection on static hosting, use the X-Frame-Options header through your hosting platform's header configuration.

Dealing with third party scripts#

This is the part where your beautiful CSP meets reality. Google Analytics. Stripe. Intercom. Sentry. HubSpot. Every third party script you add is another domain you need to allowlist, and some of them use eval() internally, which means you might need 'unsafe-eval' for script-src as well.

Here is the pragmatic approach. Start with the strictest policy possible, then loosen it only where you must. Use Content-Security-Policy-Report-Only first so you can see exactly what breaks without actually blocking anything.

Common third party allowlists
# Google Analytics / Tag Manager script-src https://www.googletagmanager.com https://www.google-analytics.com; img-src https://www.google-analytics.com; connect-src https://www.google-analytics.com; # Stripe script-src https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; # Sentry script-src https://browser.sentry-cdn.com; connect-src https://*.sentry.io; # Intercom script-src https://widget.intercom.io https://js.intercomcdn.com; connect-src https://api-iam.intercom.io wss://*.intercom.io; img-src https://static.intercomcdn.com; frame-src https://intercom-sheets.com;

A word of warning about 'strict-dynamic': when you use it, the browser ignores host allowlists in the same directive. So if your CSP has script-src 'nonce-abc' 'strict-dynamic' https://cdn.example.com, the https://cdn.example.com part is ignored by modern browsers. It is only there as a fallback for older browsers that do not understand strict-dynamic. Your third party scripts must be loaded by a nonced script (which is usually how they work anyway, loaded by your app code).

Always start in report only mode#

This is the single most important piece of advice in this entire guide. Do not deploy an enforcing CSP to production without testing it in report only mode first. The header Content-Security-Policy-Report-Only applies the same parsing and violation detection, but it does not actually block anything. Instead, it reports what would have been blocked.

Deploy your CSP in report only mode. Watch the browser console. Watch your violation reports. You will be surprised at how many things you missed. That one marketing script someone added six months ago. The legacy image loaded over HTTP. The font from Google Fonts you forgot to allowlist. Fix all of those. Then, once the violation reports are clean, flip it to enforcing mode.

Gradual rollout strategy
// Phase 1: Report only (deploy this first, leave it for a week) res.set('Content-Security-Policy-Report-Only', csp); // Phase 2: Enforce + report (catch anything you missed) res.set('Content-Security-Policy', csp); // Optional: Add a report endpoint to collect violations // report-uri /api/csp-report; report-to csp-endpoint

Mistakes we see all the time#

After scanning thousands of React and Next.js sites, these are the patterns that come up again and again. Avoid them and you will be ahead of most teams.

  • Using 'unsafe-inline' and 'unsafe-eval' together. This is the same as having no CSP at all. If both are present in script-src, an attacker can inject and execute anything.
  • Hardcoding a nonce. The whole point of a nonce is that it changes on every request. If you hardcode the same value, an attacker who reads your page source now has the nonce forever.
  • Wildcard domains in script-src. Something like script-src https://*.googleapis.com is too broad. Attackers host malicious scripts on Google Cloud Storage, which matches that pattern.
  • Forgetting connect-src. Your React app makes API calls. If you lock down script-src but leave connect-src wide open, an attacker who gets script execution can still exfiltrate data.
  • Not setting object-src and base-uri. These are easy wins. Set object-src 'none' and base-uri 'self'. Flash and Java plugins are dead, and base tag injection is a real XSS vector that most people do not think about.
  • Setting CSP once and forgetting it. Your app evolves. New dependencies, new API endpoints, new third party integrations. Review your CSP every time you add something that loads external resources.

Testing your CSP quickly#

Before we wrap up, here is a quick workflow for validating that your CSP is working correctly.

  • Run a scan. Use SiteSecurityScore to scan your deployed site. The report will show whether your CSP is present, how strong it is, and what directives are missing.
  • Check the browser console. Open DevTools on your deployed site and look for CSP violation messages. Each one tells you exactly what was blocked and which directive caused it.
  • Test all user flows. CSP violations often only appear on specific pages or after specific user actions. Click through your entire app. Login, make API calls, test payment flows, trigger error states.
  • Validate the policy itself. Use our CSP Generator to see your policy parsed into its individual directives. This makes it easy to spot typos, redundant rules, or overly permissive sources.

Quick reference: which strategy fits your setup#

SetupBest strategyStrength
Next.js App RouterNonces via middleware + strict-dynamicStrongest
Next.js Pages RouterNonces via _document.js + custom serverStrong
CRA + Express/NginxNonces injected at serve timeStrong
CRA + static hostingINLINE_RUNTIME_CHUNK=false + hash CSPModerate
Vite + static hostingNo inline scripts by default + meta tag CSPModerate

The bottom line#

CSP in React and Next.js is not as hard as it seems once you understand why things break. The build tools inject inline scripts. CSS in JS injects inline styles. Third parties load external resources. Each of these conflicts with a strict CSP, but each has a proven solution.

The best approach for most teams is nonces with strict-dynamic. It scales with your app, handles code splitting gracefully, and provides real protection against XSS. If you cannot use nonces (because of static hosting), eliminate inline scripts and use hashes. And no matter what, start in report only mode.

A CSP that blocks even 80% of attack vectors is infinitely better than no CSP at all. Do not let perfect be the enemy of deployed.

Frequently asked questions#

Why does my React app break when I add a Content Security Policy?

React build tools (Create React App, webpack) inject inline scripts into your HTML by default. A strict CSP blocks inline scripts, which causes the app to fail. The fix is to either disable inline chunks with INLINE_RUNTIME_CHUNK=false, use nonces, or use script hashes in your CSP.

How do I add CSP nonces in Next.js?

In Next.js App Router (13.4+), generate a nonce in middleware using crypto.randomUUID(), set it as a CSP header with script-src 'nonce-VALUE' 'strict-dynamic', and pass it to your root layout via a custom header. Next.js automatically applies the nonce to its internal inline scripts.

Can I use Content Security Policy with styled components or Emotion?

Yes. Both styled components (v6+) and Emotion support a nonce prop. Pass your server generated nonce to StyleSheetManager (styled components) or createCache (Emotion), and every dynamically injected style tag will carry the nonce, satisfying your CSP style-src directive.

What is strict-dynamic and why is it important for React apps?

The 'strict-dynamic' CSP keyword allows scripts loaded by an already trusted (nonced) script to execute without their own nonce. This is essential for code split React apps where dozens of chunk files load dynamically. Without it, you would need to nonce or hash every single chunk.

How do I set up CSP for Create React App on static hosting?

Set INLINE_RUNTIME_CHUNK=false in your .env.production to prevent inline scripts. Then add a CSP via a meta tag in public/index.html or through your hosting provider's header configuration. For stronger protection, compute SHA-256 hashes of any remaining inline scripts and include them in your CSP.

Was this helpful?
Share

Is your CSP actually protecting your site?

Scan your website to see how your Content Security Policy scores against real world attack vectors.