Web Security

Content Security Policy: From Zero to Production in One Guide

A complete guide to deploying Content Security Policy headers, starting from report-only mode and incrementally building to full enforcement, including nonce-based CSP for inline scripts and testing strategies.

November 1, 20257 min readShipSafer Team

Content Security Policy (CSP) is the most powerful browser-based defense against Cross-Site Scripting (XSS) attacks. A properly configured CSP means that even if an attacker manages to inject a script into your HTML, the browser will refuse to execute it. Despite this, CSP adoption remains low among production applications because getting from zero to a working policy without breaking your app requires a methodical approach. This guide walks through that process step by step.

How CSP Works

CSP is delivered as an HTTP response header (or <meta> tag, though the header is strongly preferred). It tells the browser which sources of content are legitimate and should be executed. Everything else is blocked.

A CSP header looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'

This policy says: by default, only load content from the same origin. Scripts can come from the same origin or cdn.example.com. Styles can come from the same origin or be inline (unsafe, but temporarily allowed).

The directives you will use most:

  • default-src: Fallback for all resource types not explicitly covered
  • script-src: JavaScript sources
  • style-src: CSS sources
  • img-src: Image sources
  • connect-src: Fetch, XHR, WebSocket, EventSource destinations
  • font-src: Font sources
  • frame-src: Iframe sources
  • form-action: Where forms can submit
  • base-uri: Restricts <base> tag URIs (important — prevents base URL injection)
  • object-src: Plugins (set to 'none' — you do not use Flash in 2025)

Step 1: Enable Report-Only Mode

Never deploy CSP in enforcement mode on an existing application without first observing what it would block. Report-only mode allows you to collect violations without breaking anything:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

With this header, the browser reports policy violations to your /csp-report endpoint but does not block anything. This gives you a picture of what your policy needs to permit before you enforce it.

Set up a simple report collection endpoint:

// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';

export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = await request.json();
  const report = body['csp-report'];

  logger.warn('CSP violation', {
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    documentUri: report['document-uri'],
    sourceFile: report['source-file'],
    lineNumber: report['line-number']
  });

  return NextResponse.json({}, { status: 204 });
}

Alternatively, use a hosted report collector like report-uri.com (free tier available) which provides dashboards and aggregation out of the box.

Leave report-only mode running for at least a week, covering all pages and user flows in your application. Review the violation reports to understand what your policy needs to allow.

Step 2: Building Your Policy Incrementally

Start with the strictest possible policy and relax it based on your violation reports. The goal is to avoid 'unsafe-inline' and 'unsafe-eval' in script-src — these directives significantly weaken CSP.

Common sources you will likely need to allow:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;
  style-src 'self' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https://www.google-analytics.com https://res.cloudinary.com;
  connect-src 'self' https://www.google-analytics.com https://api.example.com;
  frame-src https://www.youtube.com https://player.vimeo.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  report-uri /csp-report

Each violation report will tell you exactly what blocked URI and directive was involved. Work through the reports systematically, adding only what is genuinely needed.

Handling inline scripts: The most common source of 'unsafe-inline' violations is inline scripts — <script> tags with code inside them rather than external src attributes. The right solution is to externalize these scripts, but for complex applications with many inline scripts, this can be a large refactoring effort.

Step 3: Nonce-Based CSP for Inline Scripts

If you have inline scripts that cannot easily be externalized, nonces are the correct solution. A nonce (number used once) is a random value that you generate on the server for each request and include in both the CSP header and the inline script tags. The browser only executes inline scripts whose nonce matches the one in the CSP header.

Generating nonces in Next.js (using middleware):

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

export function middleware(request: NextRequest): NextResponse {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: blob:`,
    `font-src 'self'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    // Switch to enforcement after testing:
    // Remove -Report-Only below
  ].join('; ');

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

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy-Report-Only', cspHeader);

  return response;
}

In your root layout, retrieve the nonce and apply it to scripts:

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

export default async function RootLayout({ children }) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce') ?? '';

  return (
    <html>
      <head>
        <script nonce={nonce} dangerouslySetInnerHTML={{
          __html: `window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}`
        }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

'strict-dynamic': When combined with a nonce, strict-dynamic tells the browser to trust scripts loaded by the nonce-validated script, regardless of their source. This allows dynamically loaded scripts (lazy imports, analytics loaded by GTM) to execute without needing to be individually whitelisted. Use 'strict-dynamic' with caution — it trusts any script that a trusted script loads.

Step 4: Handling eval() and Dynamic Code Execution

'unsafe-eval' is needed when code uses eval(), new Function(), setTimeout(string), or setInterval(string). These are XSS vectors and their presence in your codebase should be investigated.

Common sources of eval in modern web applications:

  • Vue.js template compiler (in browser build): Use the runtime-only build
  • Webpack in development mode: Development builds use eval for source maps. Production builds do not.
  • Some charting libraries: Check your library's CSP compatibility
  • Handlebars, EJS templates compiled in browser: Move compilation server-side

Eliminate eval usage rather than allowing 'unsafe-eval'.

Step 5: Third-Party Script Management

Third-party scripts are CSP's biggest challenge. Each analytics tool, chat widget, A/B testing platform, and ad network may load additional scripts dynamically. You have two options:

Option 1: Whitelist all sources — Add each domain to script-src. This works but your policy grows unwieldy and may provide incomplete protection.

Option 2: Load third-party scripts through a proxy or GTM — Load all third-party scripts through Google Tag Manager, then whitelist only GTM in your script-src. GTM handles the rest. This reduces the number of domains in your policy but means GTM has a very privileged position.

Option 3: Use 'strict-dynamic' — With a nonce and 'strict-dynamic', scripts dynamically loaded by your CSP-approved scripts are automatically trusted. This is the most practical approach for complex applications with many third-party dependencies.

Step 6: Switching to Enforcement Mode

After at least two weeks of report-only monitoring with near-zero violations, switch from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the report-uri or report-to directive — you still want to receive violation reports after enforcement to catch things you may have missed.

The complete enforcement header for a modern Next.js application:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{NONCE}' 'strict-dynamic' https:;
  style-src 'self' 'nonce-{NONCE}';
  img-src 'self' data: blob: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com https://vitals.vercel-insights.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self' https://hooks.stripe.com;
  frame-ancestors 'none';
  upgrade-insecure-requests;
  report-uri /csp-report

Note the https: fallback in script-src — this allows any HTTPS script source for browsers that do not support 'strict-dynamic' (primarily older Safari versions).

Testing Your CSP

Before and after enforcement, verify your CSP using:

  • Google's CSP Evaluator (csp-evaluator.withgoogle.com): Pastes your policy and identifies weaknesses.
  • Observatory by Mozilla (observatory.mozilla.org): Scores your overall header security including CSP.
  • Browser DevTools: The Security tab in Chrome and Firefox shows the active CSP and any violations in the Console.
  • Automated tests: Add a test that fetches a response from your application and asserts the Content-Security-Policy header is present and contains the expected directives.
// __tests__/security-headers.test.ts
it('returns a Content-Security-Policy header', async () => {
  const response = await fetch('http://localhost:3000/');
  const csp = response.headers.get('content-security-policy');
  expect(csp).toBeTruthy();
  expect(csp).toContain("object-src 'none'");
  expect(csp).toContain("base-uri 'self'");
  expect(csp).not.toContain("'unsafe-eval'");
});

CSP is not a one-and-done configuration. Every new third-party integration, every new feature that uses inline scripts or styles, and every updated library may require a policy update. Build CSP review into your feature deployment checklist.

CSP
Content Security Policy
XSS
web security
security headers
nonce

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.