Content-Security-Policy: the browser-enforced second line
- Read a CSP header directive by directive, including which directives fall back to
default-srcand which don't - Predict which scripts a policy blocks:
'unsafe-inline'is ignored once a nonce or hash is present, and'strict-dynamic'switches off host lists - Generate a nonce that is fresh and unguessable on every response, and build a strict policy around it
- Roll a policy out with
Content-Security-Policy-Report-Onlyandreport-to, and test it in the mode it will run in - Choose between nonces (dynamic rendering), a static
'unsafe-inline'policy and SRI hashes in a Next.js 16 app
Read
CSP is an allow-list the browser enforces
FlagThe xss unit was about keeping attacker text out of HTML and script sinks. Content-Security-Policy
(CSP) is the second line: a response header that tells the browser which scripts, styles,
images and connections this page is allowed to use. If an injected payload slips through, the
browser refuses to run it. MDN calls it "a backup in case the other defenses fail" — the server
still sends the bad HTML; the browser just won't execute what the policy doesn't allow.
A policy is a list of directives separated by semicolons. Each directive is a name followed by the sources it allows:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m' https://js.stripe.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
| Directive | Reads as |
|---|---|
default-src 'self' | images, styles, fonts, connections and other fetches not listed below may come only from this page's own origin |
script-src 'self' 'nonce-r4nd0m' https://js.stripe.com | scripts from our origin, inline scripts carrying nonce="r4nd0m", and scripts from Stripe |
object-src 'none' | no <object> or <embed> at all |
base-uri 'none' | the page may not use a <base> element |
frame-ancestors 'none' | no page, not even one of ours, may put this one in a frame |
Two spelling rules: keywords are quoted ('self', 'none', 'nonce-…'), and hosts and
schemes are not (https://js.stripe.com, https:). self without quotes would mean a host
called "self".
What this buys you against XSS: once a policy has script-src (or default-src) without
'unsafe-inline', the browser blocks inline <script> blocks that don't carry the nonce, inline
event handlers like onerror="…" and onclick="…", and javascript: URLs; eval() and its
relatives are blocked too. The <img src=x onerror=…> payload from the previous unit lands in the
page and does nothing.
A nonce per response, line by line
FlagA plain Node server that sends a nonce-based policy. Run it and request the page twice: the two responses carry different nonces.
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";
createServer((req, res) => {
const nonce = randomBytes(16).toString("base64"); // 128 bits, fresh for this response
const csp = [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
].join("; ");
res.setHeader("Content-Security-Policy", csp);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!doctype html><script nonce="${nonce}" src="/app.js"></script>`);
}).listen(3000);
$ curl -si localhost:3000 | grep -i content-security-policy
Content-Security-Policy: script-src 'nonce-bfiayeQQooD+DrlcufrbdA==' 'strict-dynamic'; object-src 'none'; base-uri 'none'
$ curl -si localhost:3000 | grep -i content-security-policy
Content-Security-Policy: script-src 'nonce-DFsSSeZaNYL/9GSWRxdKMg==' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Why each piece is there:
randomBytes(16)inside the handler. The nonce is a per-response secret. Generated once at start-up, it would be printed in every page anyone loads — including the attacker's — and they'd simply put it on their injected script. 16 bytes is the spec's recommended 128-bit minimum, from a cryptographically secure source (Math.random()is not one).- The same nonce in the header and on our tag, and only on tags we wrote. MDN warns against
templating that adds the nonce to every
<script>in the output: an injected tag would get one too. 'strict-dynamic'.app.jsis trusted because its tag has the nonce. Scripts it adds withdocument.createElement("script")inherit that trust, so a bundler's chunk loader or a tag manager works without listing every host. The next reading covers the catch.object-src 'none'. Plugin content (<object>,<embed>) is an old route around script restrictions; nothing modern needs it.base-uri 'none'. An injected<base href="https://evil.example">would make the relative/app.jsload from the attacker's host — with our valid nonce on the tag. The spec uses exactly this example.base-uricloses it, and it has to be stated explicitly because it doesn't fall back todefault-src.
This is a strict CSP. MDN's version is a nonce- or hash-based script-src plus
object-src 'none' and base-uri 'none'; the CSP Level 3 draft's version is nonces or hashes with
'strict-dynamic', plus base-uri 'self' or 'none'. Notice what both leave out: a list of
allowed hosts. Host allow-lists are, in MDN's words, "hard to get right" and "can be very large and
hard to maintain".
Rolling a policy out without breaking production
FlagAn enforced policy that's wrong breaks the site: a blocked analytics loader, a payment iframe that never appears, a chunk that won't load. The way in is to report first, enforce second.
Report-Only. The same policy sent under a different header name is evaluated but not enforced — MDN: "The policy is not enforced, but any violations are sent to the reporting endpoint specified in the policy."
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-R' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
Reporting-Endpoints names an endpoint; report-to in the policy points at it. Browsers POST
reports as application/reports+json with "type": "csp-violation". The older report-uri
directive is deprecated, but MDN advises declaring both until report-to works everywhere —
browsers that support report-to ignore report-uri. (MDN's compat data lists report-to support
in Firefox only from version 149.)
Both headers can be sent at once, and both are honoured: keep enforcing the policy you trust while the stricter one only reports. Fix or allow each legitimate violation the reports show; when only violations you intend to block are left, move the strict policy to the enforcing header.
More than one policy only ever tightens. If a response carries two enforced CSP headers, a resource must pass both. MDN: adding policies "can only further restrict the capabilities of the protected resource". A CDN or platform that adds its own CSP header on top of yours can block what your policy allows.
The <meta> tag is a partial substitute. The CSP spec: Report-Only isn't supported in a meta
element, and "Neither are the report-uri, frame-ancestors, and sandbox directives". A
frame-ancestors 'none' in a meta tag protects against nothing.
Test the policy where it actually runs. The most common way to ship a broken policy is to set it only in production:
if (process.env.NODE_ENV === "production") res.setHeader("Content-Security-Policy", csp);
Every page then works in development, because no policy exists there; the inline script someone
added last week is only blocked for real users. Frameworks also differ between modes — Next.js's
development server needs 'unsafe-eval' (React uses eval for debugging information) while its
production build doesn't. Run the policy in development too (adding only what dev genuinely
needs), and click through a local production build with the policy on before you ship it.
Quiz
Which statement about Content-Security-Policy is accurate?
FlagThe policy is default-src 'none'; script-src 'self'; style-src 'self'. Which of these is still unrestricted?
To save work, a teammate generates the CSP nonce once when the server starts and reuses it for every response. What's wrong with that?
FlagTo make the policy "just work", a post-processing step adds the response's nonce to every <script> tag in the outgoing HTML. Why does that defeat CSP?
You're adding a strict CSP to a production app that has a tag manager and a payment widget. What's the safe first step?
FlagYour app sets CSP only if (process.env.NODE_ENV === "production"). Every page works under next dev. What's the likely outcome?
In a Next.js 16 app you add a nonce-based CSP in proxy.ts. What happens to pages that used to be statically generated?
A Next.js 16 marketing site must stay statically generated and CDN-cached, and security won't accept 'unsafe-inline' in script-src. Which approach in the Next.js CSP guide fits?
Fill in the blank
Write the nonce-based strict policy: trust propagates from nonced scripts, plugins are off, and <base> is locked.
Content-Security-Policy: script-src 'nonce-{RANDOM}' ''; object-src ''; base-uri 'none'
Generate a 128-bit nonce for this response in Node, base64-encoded for the header.
Flagimport { randomBytes } from "node:crypto"; const nonce = (16).toString("");
Trial a policy without enforcing it, sending violation reports to a named endpoint.
FlagReporting-Endpoints: csp-endpoint="https://example.com/csp-reports" Content-Security-Policy-: script-src 'nonce-R' 'strict-dynamic'; csp-endpoint
In a Next.js 16 Server Component, read the nonce that proxy.ts put on the request, for a third-party script.
const nonce = (await headers()).get(""); return <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" nonce={nonce} />;
A Next.js 16 page reads no request data, but it must render per request so a fresh nonce can be applied. Opt it into dynamic rendering.
Flagimport { connection } from "next/server"; export default async function Page() { await (); // … }
Predict the output
Content-Security-Policy: default-src 'self'; script-src https://js.stripe.com <script src="/app.js"></script> <!-- same origin --> <script src="https://js.stripe.com/v3/"></script>
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-R' <script nonce="R">boot()</script> <script>steal()</script> <!-- injected by an attacker --> <button onclick="buy()">Buy</button> <!-- clicked -->
Content-Security-Policy: script-src 'nonce-R' 'strict-dynamic' https://js.stripe.com
<script src="https://js.stripe.com/v3/"></script> <!-- no nonce -->
<script nonce="R" src="/loader.js"></script>
// inside /loader.js:
const s = document.createElement("script");
s.src = "https://cdn.example.net/widget.js";
document.head.append(s);Challenge
Fix the per-request CSP builder
Flagcsp.ts builds the Content-Security-Policy header for one HTML response. The app's proxy calls
cspForRequest() on every page request, sends header as the response header, and puts nonce
on the page's own <script> tags. csp-parse.ts splits a header into its directives the way the
CSP spec does; the preview uses it to show three requests side by side.
The policy is sent and the site works, but it would barely slow down an XSS payload:
'unsafe-inline'lets every inline script run, the attacker's included. The page's own inline scripts should be allowed by the response's nonce instead (and the nonce isn't in the header at all yet).- The nonce is created once, when the module loads, so every response carries the same one. Anyone can load a page, read it, and put it on their injected script.
- No
object-srcorbase-uri. Through thedefault-src 'self'fallback,<object>and<embed>may still load anything from our own origin (user uploads included).base-uridoesn't fall back todefault-srcat all, so an injected<base href="https://evil.example">could re-point the page's nonced relative script URLs. 'unsafe-eval'in production. React's development build needs it; production doesn't, and a nonce doesn't switch'unsafe-eval'off the way it switches'unsafe-inline'off.
Your task
Edit csp.ts so the tests pass:
script-srcallows inline script only through'nonce-…'with this response's nonce, and contains no'unsafe-inline'- Two calls to
cspForRequestreturn two different nonces - The policy has
object-src 'none'and an explicitbase-uri('none'or'self') 'unsafe-eval'is inscript-srconly whendevistrue- The configured script and connect origins,
frame-ancestors 'none'andreport-toare still there - Nonces are base64 and carry at least 128 bits