🔧 Validate Your CSP Policy

Copy a CSP example, deploy it, then verify it works with a real scan.

Verify with headertest.com →

Single Page Applications have a unique relationship with CSP. The development workflow depends on things CSP doesn’t like — eval for HMR, inline styles for CSS-in-JS, dynamic script loading. But in production, SPAs actually benefit enormously from CSP because they’re JavaScript-heavy.

Here are configurations that work for each major framework, based on production deployments.

React (Vite)#

Vite is the recommended build tool for new React projects.

Development#

// vite.config.js
export default defineConfig({
  plugins: [react()],
  server: {
    headers: {
      'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'"
    }
  }
});

You need unsafe-eval for Vite’s hot module replacement. You need ws: in connect-src for the WebSocket connection Vite uses to push HMR updates. You need blob: in img-src because some bundlers use blob URLs for certain assets.

These are development-only concessions. In production, you can be much stricter.

Production#

Deploy via Nginx or your reverse proxy:

location / {
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self'; connect-src 'self' https://api.yoursite.com; frame-ancestors 'none'; base-uri 'self'" always;
    try_files $uri $uri/ /index.html;
}

React with CSS-in-JS#

If you use styled-components, emotion, or similar:

style-src 'self' 'unsafe-inline'

CSS-in-JS libraries inject <style> tags at runtime. CSP blocks these without unsafe-inline. This is the pragmatic choice — CSS can’t execute JavaScript, so the security impact is minimal.

If you want to be stricter, use the nonce support that most CSS-in-JS libraries provide:

import { StyleSheetManager } from 'styled-components';

function App({ nonce }) {
  return (
    <StyleSheetManager nonce={nonce}>
      <YourComponent />
    </StyleSheetManager>
  );
}

React with Next.js (see separate section below)#

Vue.js (Vite)#

Very similar to React:

Development#

// vite.config.js
export default defineConfig({
  plugins: [vue()],
  server: {
    headers: {
      'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; connect-src 'self' ws:; frame-ancestors 'none'"
    }
  }
});

Production#

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

Vue’s single-file components compile to regular JavaScript and CSS in production, so there are no special CSP concerns beyond what’s listed above.

Angular#

Angular’s build process tree-shakes and bundles everything, which makes CSP relatively straightforward:

Production (Nginx)#

location / {
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'" always;
    try_files $uri $uri/ /index.html;
}

Angular with SSR (Angular Universal)#

If you’re using server-side rendering, you can generate nonces:

// server.ts
import { randomUUID } from 'crypto';

app.get('*', (req, res) => {
  const nonce = Buffer.from(randomUUID()).toString('base64');
  res.setHeader('Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; frame-ancestors 'none'; base-uri 'self'`
  );
  res.render('index', { req, res, nonce });
});

Then in your template:

<script nonce="<%= nonce %>" src="main.js"></script>

Next.js (App Router)#

Next.js has the best CSP story of any framework thanks to middleware:

// middleware.js
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';

export function middleware(request) {
  const nonce = Buffer.from(randomUUID()).toString('base64');
  
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data: https:`,
    `font-src 'self'`,
    `connect-src 'self' https://api.yoursite.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('x-nonce', nonce);
  response.headers.set('Content-Security-Policy', cspHeader);
  return response;
}

export const config = {
  matcher: [
    { source: '/((?!api|_next/static|_next/image|favicon.ico).*)', },
  ],
};

The matcher config is important — it excludes API routes, static files, and Next.js image optimization from CSP processing. These don’t need CSP headers and adding them would be wasteful.

Using the nonce in Next.js layouts:#

// app/layout.js
import { headers } from 'next/headers';

export default function RootLayout({ children }) {
  const nonce = headers().get('x-nonce');
  return (
    <html lang="en">
      <head>
        <script nonce={nonce} src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Next.js Image Optimization#

Next.js uses /_next/image as a proxy endpoint. Make sure img-src includes 'self' and blob: — Next.js sometimes uses blob URLs for optimized images.

Common SPA Issues#

Service Workers#

If you use service workers (PWA), add worker-src:

worker-src 'self'

WebAssembly#

If you load .wasm modules:

script-src 'self' 'wasm-unsafe-eval'

Note: wasm-unsafe-eval only allows WebAssembly compilation. It does NOT allow JavaScript eval(). They’re separate permissions.

Third-Party Scripts Loading Dynamically#

If a third-party script loads additional scripts (like GTM loading analytics), you have two options:

  1. List every domain — Tedious and fragile
  2. Use ‘strict-dynamic’ — Scripts loaded by trusted scripts are automatically trusted

If you’re using nonces, always pair them with strict-dynamic.

Dynamic Imports#

const module = await import('./heavy-module.js');

Dynamic imports work fine with CSP as long as the chunks are served from an allowed origin. No special configuration needed — Vite, Webpack, and Next.js all serve chunks from the same origin.

Verify Your CSP#