React / SPA guide

Content-Security-Policy for React and Vite Apps (What Actually Works)

A strict CSP that works perfectly on a server-rendered site will break a React or Vite app in at least three ways before your first user logs in. The problems are specific and fixable — but they require a different starting policy than the generic CSP guides recommend.

Test in report-only mode first. Set Content-Security-Policy-Report-Only before Content-Security-Policy. Watch the browser console for violations across every page flow. Fix them in the policy before switching to the enforcing header. This applies to SPAs especially — a single-page app has many code paths that won't be covered by loading the homepage once.

Why React apps are harder for CSP

A traditional server-rendered site generates HTML on every request. That per-request rendering makes it practical to inject a fresh random nonce into both the CSP header and every legitimate inline script, so a strict script-src 'nonce-xxx' policy with no 'unsafe-inline' is achievable.

A React or Vite single-page app works differently. Your server (or CDN) delivers a static index.html shell on every request — no per-request rendering. The JavaScript runtime runs entirely in the browser and generates DOM at runtime. That architecture creates three CSP problems that don't exist on server-rendered sites:

  1. The bundler may inject a small inline <script> block into index.html at build time.
  2. CSS-in-JS libraries (styled-components, Emotion, MUI) inject <style> tags into the document at runtime.
  3. Development tools (React Fast Refresh) use eval() — and some production libraries do too.

The three violations you'll hit immediately

1. Inline bootstrap script

Vite injects a module-preload polyfill as an inline <script> in your built index.html. A policy with only script-src 'self' blocks it. Console: "Refused to execute inline script because it violates the following Content Security Policy directive"

2. CSS-in-JS inline styles

styled-components, Emotion, MUI, and Chakra UI all write <style> blocks into <head> at runtime. A policy with only style-src 'self' breaks your entire UI. Console: "Refused to apply inline style because it violates the following Content Security Policy directive"

3. eval() in dev and some libraries

React Fast Refresh (Vite HMR) uses eval() for hot module replacement in development. Some production libraries — PDF.js, Monaco Editor, certain charting libraries — also use eval(). Console: "Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source"

Production starting policy

This is a safe starting point for a Vite or Create React App production build hosted on a static CDN, without server-side rendering. It is intentionally practical rather than maximally strict — the restrictions you'll actually be able to enforce without breaking your app.

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob:;
  connect-src 'self';
  font-src 'self';
  worker-src 'self' blob:;
  frame-ancestors 'none';

What each line does and why it's written this way:

DirectiveWhy this value
script-src 'self' Only scripts from your own origin execute. This is correct for a Vite production build where all JS is bundled and served from your origin. Do not add 'unsafe-eval' here unless a specific production library requires it.
style-src 'self' 'unsafe-inline' 'unsafe-inline' is required for styled-components, Emotion, MUI, Chakra UI, and any library that writes <style> blocks at runtime. This does not enable script injection — style-src governs CSS only. See the CSS-in-JS nonces section for the strict alternative.
img-src 'self' data: blob: data: covers base64-embedded images and inline SVG data URIs. blob: covers URL.createObjectURL() used for file upload previews, canvas exports, and client-side image generation. Remove blob: if you don't use those patterns.
connect-src 'self' Governs fetch(), XMLHttpRequest, and WebSocket connections. You'll need to add every API origin your app calls: connect-src 'self' https://api.example.com. Add ws://localhost:* only in development (see below).
worker-src 'self' blob: blob: covers web workers created from new Worker(URL.createObjectURL(blob)) — a common pattern for offloading heavy computation. Remove blob: if you don't use web workers.
frame-ancestors 'none' Prevents your app from being embedded in an iframe — the modern equivalent of X-Frame-Options: DENY. Unless you intentionally embed your app in another site, always include this.
Add third-party API origins explicitly. If your React app calls https://api.stripe.com, https://api.openai.com, or any other external endpoint, add each one to connect-src. Prefer exact origins over wildcards — https://api.stripe.com is better than https://*.stripe.com.

Handle the bundler's inline script

Vite's production build injects a small inline script in index.html for the module preload polyfill. This script is blocked by script-src 'self' because it is inline, not served from your origin. You have three options:

Option 1: Disable the polyfill (simplest)

If your target browsers all support rel="modulepreload" natively (Chrome 66+, Firefox 115+, Safari 17+), you don't need the polyfill. Disable it in vite.config.ts:

// vite.config.ts
export default {
  build: {
    modulePreload: {
      polyfill: false   // removes the inline bootstrap script entirely
    }
  }
};

Option 2: Hash the inline script

Build your app (vite build), then inspect the inline <script> block in dist/index.html. Compute its SHA-256 hash and add it to script-src:

# On macOS:
cat dist/index.html | grep -o '<script>.*</script>' | \
  sed 's/<script>//;s/<\/script>//' | \
  openssl dgst -sha256 -binary | base64

# Then add to your CSP:
script-src 'self' 'sha256-<the-hash-you-got>';

The Vite build output is deterministic — the hash only changes when the polyfill script changes (i.e., when you upgrade Vite). Regenerate and redeploy the header when that happens.

Option 3: Vite's cspNonce plugin option (Vite 5+)

If you have a server that can set response headers dynamically (not a fully static CDN), Vite 5 added a build.rollupOptions.plugins-level cspNonce configuration. This adds a nonce="VITE_NONCE" placeholder to inline scripts at build time; your server replaces the placeholder with a fresh nonce per request and sets the matching Content-Security-Policy: script-src 'nonce-xxx' header. This is the strictest approach but requires a dynamic server — it doesn't work on Netlify or Vercel static hosting.

For Create React App: eliminate the inline script entirely

# .env.production
INLINE_RUNTIME_CHUNK=false

Setting this env var moves CRA's runtime chunk from an inline <script> to a separate JS file, served from your origin as a normal script. No inline script, no CSP violation, no hash needed.

Dev mode needs a different policy

Vite's development server and React Fast Refresh use features that should never be in your production CSP:

  • 'unsafe-eval' in script-src — React Fast Refresh uses eval() for hot module replacement. Required in dev; remove in production.
  • ws://localhost:* in connect-src — Vite's HMR opens a WebSocket to the dev server. Required in dev; not needed in production.

The practical solution: only set the CSP header in your production server configuration (Netlify _headers, Vercel vercel.json, nginx). The Vite dev server does not send CSP headers by default, so your development environment runs without the policy. This means you're testing CSP only in production or staging — which is why the Content-Security-Policy-Report-Only step is important before switching to enforcing mode.

CSS-in-JS nonces — the strict approach

'unsafe-inline' in style-src is the practical default for static SPA deployments. If you need a stricter policy — for example, a compliance requirement prohibits any 'unsafe-inline' value — CSS-in-JS libraries support nonce-based injection, but only when you have server-side rendering.

  • styled-components: wrap your app in <StyleSheetManager nonce={nonce}>
  • Emotion: use <CacheProvider value={createCache({ key: 'css', nonce })}>
  • MUI: pass nonce to createCache() in the MUI CacheProvider setup
  • Chakra UI: pass cssVarsRoot and a nonce to ChakraProvider

Each library must receive the same nonce that your server injected into the CSP header for that request. This only works with Next.js, Remix, or another SSR framework that renders HTML per request — not with a static index.html served from a CDN. For fully static deployments, 'unsafe-inline' in style-src is the correct and accepted choice.

Deployment snippets

These snippets use the production starter policy above. Replace https://api.example.com with your actual API origin, and remove blob: from img-src and worker-src if you don't use those patterns. If you used Option 2 for the Vite inline script, add your 'sha256-xxx' to the script-src value.

Netlify (_headers)

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://api.example.com; font-src 'self'; worker-src 'self' blob:; frame-ancestors 'none';

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://api.example.com; font-src 'self'; worker-src 'self' blob:; frame-ancestors 'none';"
        }
      ]
    }
  ]
}

nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://api.example.com; font-src 'self'; worker-src 'self' blob:; frame-ancestors 'none';" always;
Using Google Fonts? Add https://fonts.googleapis.com to style-src and https://fonts.gstatic.com to font-src. If you're using a tag manager (GTM, Segment), see the strict-dynamic guide for how to allow dynamically injected scripts without 'unsafe-inline' in script-src.

Verify with HardenCheck

Once your production site is live with the policy set, paste your raw response headers into HardenCheck's paste mode. Run curl -I https://your-app.com in a terminal to get the exact headers your server sends — paste the output directly. HardenCheck will grade your CSP, confirm frame-ancestors is set, and flag any remaining weaknesses like a missing object-src or an overly broad default-src.

If you're still seeing violations in the browser console after deploying, use report-only mode to collect and review them systematically rather than chasing each one individually.

Grade my headers with HardenCheck →

Frequently asked questions

Does 'unsafe-inline' in style-src enable XSS?

'unsafe-inline' in style-src allows dynamically injected <style> blocks and inline style attributes. It does not allow script execution — scripts are governed by script-src, not style-src. An attacker who injects a <style> block can cause visual disruption (CSS injection) but cannot execute JavaScript through it. The risk is real but categorically lower than 'unsafe-inline' in script-src. Allowing it only in style-src is the standard practical compromise for CSS-in-JS frameworks on static deployments.

Does React itself require 'unsafe-eval' in production?

No. React's production build (NODE_ENV=production) does not use eval(). The 'unsafe-eval' requirement comes from development tools: React Fast Refresh uses eval() for hot module replacement, which is not included in production bundles. If your production app uses a library that needs eval() — PDF.js, Monaco Editor, certain chart libraries — that library's docs will tell you. For a standard Vite or CRA production build, script-src 'self' works without 'unsafe-eval'.

Why does my Vite app have an inline script in index.html?

Vite injects a module-preload polyfill as an inline <script> in your built index.html — a shim for browsers that don't support rel="modulepreload" natively. You have three options: (1) disable it with build.modulePreload: { polyfill: false } in vite.config.ts if your target browsers don't need it; (2) hash the inline script and add 'sha256-<hash>' to script-src; (3) use Vite 5's cspNonce option with a dynamic server.

What is blob: in img-src for in a React app?

blob: allows <img> tags to display URLs created by URL.createObjectURL() — used for file-upload previews, canvas exports (canvas.toBlob()), and client-side PDF thumbnails. If your app uses file inputs with image previews, or generates images from a canvas, you need blob: in img-src. If you don't use those patterns, omit it to keep your allowlist tighter.

My app calls an API at api.example.com — what do I add?

Add the exact API origin to connect-src: connect-src 'self' https://api.example.com. For multiple origins, list each one. WebSocket connections (used for live updates, chat, realtime data) are also governed by connect-src — add wss://api.example.com if you use WebSockets. Prefer exact origins over wildcards (https://api.example.com is better than https://*.example.com).

Can I use nonces with a Vite static build on Netlify or Vercel?

Not in the traditional per-request sense — nonces must be unique per request and injected server-side, which static file hosts don't do. For static deployments, hashes are the correct approach for inline scripts: compute SHA-256 of the exact inline script content and add 'sha256-<hash>' to script-src. For CSS-in-JS inline styles on static hosts, 'unsafe-inline' in style-src is the practical option. Nonce-based CSS-in-JS injection requires Next.js, Remix, or another SSR setup.

HardenCheck gave my React app an F for CSP — what should I fix first?

An F typically means no Content-Security-Policy header is present at all. Deploy the production starter policy above in report-only mode first: use Content-Security-Policy-Report-Only with the policy value. Watch the browser console across all routes and user flows for violations. Add the missing directive values one at a time, then switch to the enforcing header. Having any CSP — even with 'unsafe-inline' in style-src — is meaningfully better than none, because script-src 'self' still blocks remote script injection from unexpected domains.

Also in the Copper Bay Labs ship-safety suite

Security headers are one layer. These free tools cover the rest of the pre-ship checklist: