devkult_
tools29converters34
home/blog/content-security-policy-guide
securityhttpheaders

Content Security Policy (CSP): A Practical Guide with Nonces

Cross-site scripting works by getting the browser to run JavaScript you didn't write. A Content Security Policy is your allowlist: the browser refuses any script, style, frame or connection that the policy doesn't permit. Done well, it turns most XSS bugs into a harmless blocked request. Done badly — with 'unsafe-inline' sprinkled in to make errors go away — it protects almost nothing.

How a policy reads

A policy is a list of directives separated by semicolons. Each names a resource type and the sources allowed for it:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; img-src 'self' data:; object-src 'none'
  • default-src is the fallback for fetch directives you don't list.
  • 'self' means your own origin — scheme, host and port.
  • Quoted keywords ('self', 'none', 'unsafe-inline') need the single quotes; host sources don't.

The directives that matter most: script-src, style-src, img-src, connect-src (fetch, XHR and WebSockets), frame-src (what you embed), frame-ancestors (who may embed you), object-src, base-uri and form-action. The Content-Security-Policy reference lists them all.

Step 1: report before you block

Send the policy as Content-Security-Policy-Report-Only. The browser evaluates it and logs every violation in the console, but blocks nothing:

Content-Security-Policy-Report-Only: default-src 'self'; object-src 'none'; base-uri 'self'

Browse your site, fix or allow what shows up, then switch the header name to Content-Security-Policy. To collect violations from real users, add a reporting endpoint:

Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp; report-uri https://example.com/csp-reports

report-to is the current mechanism; report-uri is deprecated but still the one some browsers use, so sending both is common.

Step 2: deal with inline scripts

Inline <script> blocks and onclick="…" attributes are exactly what injected code looks like, so a policy without 'unsafe-inline' blocks them all. Allowing 'unsafe-inline' makes the policy almost useless against XSS. There are two proper fixes.

Nonces. Generate a random value for every response, put it in the policy, and add it to each script tag you trust:

Content-Security-Policy: script-src 'nonce-r4nd0mBase64Value' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="r4nd0mBase64Value" src="/app.js"></script>

An attacker who injects a <script> can't know the nonce, so their script doesn't run. The nonce must be unpredictable and new on every response — a fixed nonce is no better than 'unsafe-inline'.

Hashes. For a static inline script that never changes, allow its exact contents by hash instead. For <script>console.log("hello");</script>:

Content-Security-Policy: script-src 'self' 'sha256-N4H5TqgSuzNDfekEngS8OvQaDnOXFksFc3nAjDsKxIk='

The hash covers the text between the tags exactly, whitespace included — change one character and it stops matching.

You don't need to compute the hash by hand: when Chrome blocks the script, the console error includes the exact 'sha256-…' value to add.

Inline event handlers (onclick, onload) and javascript: URLs can't carry nonces. Move them into script files with addEventListener.

strict-dynamic

Modern bundles load more scripts at runtime, and analytics tags load further scripts of their own. Listing every host is brittle. 'strict-dynamic' says: trust scripts that a nonced or hashed script loads. Browsers that support it ignore host allowlists and 'self' in script-src, which is why a nonce plus 'strict-dynamic' is the policy Google's security team recommends.

Nonces in Next.js

Next.js reads the nonce from the request's CSP header and applies it to its own scripts. Generate it in middleware (called proxy.ts from Next.js 16):

import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

The catch: a per-request nonce means pages can't be served as static HTML, since each response needs a fresh value. For mostly static sites, a hash-based or host-based policy may be the better trade.

What usually breaks

Symptom Cause Fix
Analytics or chat widget stops working Its script, API and image hosts aren't allowed Add hosts to script-src, connect-src, img-src — or use a nonce with 'strict-dynamic'
"Refused to evaluate a string as JavaScript" eval() or new Function(), often in older libraries or dev builds Replace the code; 'unsafe-eval' only as a last resort
Inline styles ignored style-src without 'unsafe-inline' blocks style="" attributes Nonce style tags, or allow 'unsafe-inline' for styles only — far less dangerous than for scripts
Fonts or images missing font-src / img-src falls back to default-src Add the CDN host, or data: for inline images
Site can't be embedded where you want it frame-ancestors 'none' List the allowed parent origins

Checklist

  • Start with -Report-Only; enforce once the console is quiet.
  • No 'unsafe-inline' in script-src — use nonces or hashes.
  • Always include object-src 'none' and base-uri 'self' (or 'none').
  • Add frame-ancestors to replace X-Frame-Options.
  • Re-test after adding any third-party script.

CSP is one of six headers worth sending on every site — see the security headers checklist for the others.

More from the blog