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-srcis 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'inscript-src— use nonces or hashes. - Always include
object-src 'none'andbase-uri 'self'(or'none'). - Add
frame-ancestorsto 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.