Web Security (OWASP Top 10:2025) Module 6 – A05:2025 Injection
Content Security Policy that actually holds
Build a strict nonce or hash Content Security Policy with strict-dynamic, deploy it in report-only mode, and avoid the patterns that defeat a policy.
What you will learn
- Explain what a Content Security Policy mitigates and what it does not
- Build a strict nonce-based or hash-based policy with strict-dynamic, object-src none and base-uri none
- Deploy a policy in report-only mode, collect the reports and tighten it safely
- Avoid the patterns that weaken a policy, such as unsafe-inline, unsafe-eval, wildcard sources and host lists
- Generate a new nonce for every response on a dynamic server, and hashes for a static site
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what a Content Security Policy mitigates and what it does not
- Build a strict nonce-based or hash-based policy with strict-dynamic, object-src none and base-uri none
- Deploy a policy in report-only mode, collect the reports and tighten it safely
- Avoid the patterns that weaken a policy, such as unsafe-inline, unsafe-eval, wildcard sources and host lists
- Generate a new nonce for every response on a dynamic server, and hashes for a static site
Before you start
On this page
A Google study of 1,680,867 hosts that sent a Content Security Policy found that 99.34% of them used a policy that gave no real protection against cross-site scripting, and 75.81% of the unique policies relied on script allowlists that can be bypassed. A Content Security Policy: A response header that tells the browser which scripts, frames, forms and other resources a page may use, so that an injected script can be refused. It limits the damage of a flaw and does not remove the flaw. is a response header that tells the browser which scripts, frames, forms and other resources a page may use, and a policy that holds is one where only the scripts the server marked can run.
You will compare a host list with a nonce on the same scripts, build the strict policy one directive at a time, give a dynamic page a nonce and a static page a hash, deploy a policy in report-only mode and sort its reports, and learn the patterns that quietly defeat a policy.
A policy holds when only scripts that carry this response’s mark can run
A policy is defence in depth. It limits what an injected script can do when output encoding fails, and it does nothing
about the flaw itself. MDN lists what it covers: scripts, through script-src and its relatives; clickjacking, through
frame-ancestors; plain-HTTP subresources, through upgrade-insecure-requests; and DOM sinks, through Trusted Types.
It does not stop server-side flaws such as SQL injection, and OWASP’s cheat sheet says it should not be the only
defence against cross-site scripting. Three sources make a script runnable. A nonce: A random value, new for every response, that the server writes in the Content-Security-Policy header and in the nonce attribute of each script it trusts. The browser runs only the script tags whose nonce matches. is a random value, new for every response, that the server writes in the header and on each script tag it trusts.
A hash source: A script-src entry such as 'sha256-' followed by a base64 value, which names a script by the SHA-256 hash of its exact text. Any change to the text, even a space, gives another hash and blocks the script. names a script by the SHA-256 hash of its exact text. And 'strict-dynamic' lets a trusted script add
further scripts while the browser ignores host lists. The program below judges seven scripts against four policies, a
host list first, and marks every wrong answer with a star:
// A simplified model of how a browser judges one script against the script-src of a policy. It follows the rules that
// matter here: a nonce allows a script; 'unsafe-inline' is ignored when the list holds a nonce; 'strict-dynamic' ignores
// host sources and trusts scripts that a trusted script adds; 'unsafe-eval' lets strings become code. Hash sources are
// left out: the program on nonces and hashes, further down, computes them.
const PAGE = 'https://app.example/';
function allows(policy, script) {
const directives = Object.fromEntries(policy.split(';').map((d) => d.trim().split(/\s+/)).filter((d) => d[0]).map(([name, ...values]) => [name, values]));
const list = directives['script-src'] ?? directives['default-src'];
if (!list) return true;
const nonces = list.filter((t) => t.startsWith("'nonce-")).map((t) => t.slice(7, -1));
const strict = list.includes("'strict-dynamic'");
const unsafeInline = list.includes("'unsafe-inline'") && nonces.length === 0;
const hostAllowed = (url) => list.some((t) => (t === "'self'" ? url.startsWith(PAGE) : url.startsWith(t)));
const nonceMatches = !!script.nonce && nonces.includes(script.nonce);
if (script.kind === 'inline') return nonceMatches || unsafeInline;
if (script.kind === 'handler') return unsafeInline;
if (script.kind === 'eval') return list.includes("'unsafe-eval'");
if (nonceMatches) return true;
if (strict) return !!script.addedByTrustedScript;
return hostAllowed(script.url);
}
const policies = [
['allowlist', "script-src 'self' https://cdn.example"],
['+ unsafe-inline', "script-src 'self' 'unsafe-inline' https://cdn.example"],
['nonce, no dynamic', "script-src 'nonce-R4nd0m'; object-src 'none'; base-uri 'none'"],
['nonce + dynamic', "script-src 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'"],
];
// [what the script is, the script, whether the page wants it to run]
const scripts = [
['own inline script', { kind: 'inline', nonce: 'R4nd0m' }, true],
['own file, /app.js', { kind: 'external', url: 'https://app.example/app.js', nonce: 'R4nd0m' }, true],
['file added by our loader', { kind: 'external', url: 'https://widgets.example/w.js', addedByTrustedScript: true }, true],
['injected inline script', { kind: 'inline' }, false],
['injected tag, file on the CDN', { kind: 'external', url: 'https://cdn.example/uploads/any.js' }, false],
['inline onclick attribute', { kind: 'handler' }, false],
['eval of a string', { kind: 'eval' }, false],
];
console.log('* marks a wrong answer for this page: a script let in, or one the page needs blocked.');
const letIn = policies.map(() => 0);
const refused = policies.map(() => 0);
console.log(`${''.padEnd(30)}${policies.map(([name]) => name.padEnd(19)).join('')}`);
for (const [label, script, wanted] of scripts) {
const cells = policies.map(([, policy], i) => {
const ok = allows(policy, script);
if (ok && !wanted) letIn[i]++;
if (!ok && wanted) refused[i]++;
return `${ok ? 'runs' : 'blocked'}${ok !== wanted ? ' *' : ''}`.padEnd(19);
});
console.log(`${label.padEnd(30)}${cells.join('')}`);
}
console.log(`${'lets in what it should not'.padEnd(30)}${letIn.map((n) => String(n).padEnd(19)).join('')}`);
console.log(`${'blocks what the page needs'.padEnd(30)}${refused.map((n) => String(n).padEnd(19)).join('')}`); Output
* marks a wrong answer for this page: a script let in, or one the page needs blocked.
allowlist + unsafe-inline nonce, no dynamic nonce + dynamic
own inline script blocked * runs runs runs
own file, /app.js runs runs runs runs
file added by our loader blocked * blocked * blocked * runs
injected inline script blocked runs * blocked blocked
injected tag, file on the CDN runs * runs * blocked blocked
inline onclick attribute blocked runs * blocked blocked
eval of a string blocked blocked blocked blocked
lets in what it should not 1 3 0 0
blocks what the page needs 2 1 1 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node csp_verdicts.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The host list let in the injected tag that points at a file on the allowed CDN, and it blocked two scripts that the page
needs, its own inline script and the file its loader adds. Adding 'unsafe-inline' let in three scripts that nobody
meant to run: the injected inline script, the file on the CDN and the onclick attribute. The nonce policy without
'strict-dynamic' let in none, and still blocked the loader’s file. With 'strict-dynamic' the last column has no
star at all, because the page’s own tags carry the nonce and the file its loader adds is trusted by that loader. Report-Only mode: Sending a policy in the Content-Security-Policy-Report-Only header, so that violations are reported and nothing is blocked. It shows what an enforced policy would break before it is enforced. sends the same policy in a header that reports violations and blocks nothing, which is how a policy like that last
column reaches a real site. Step through one response under that policy:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A nonce for this response. The server makes a new random value for this response alone and keeps it in a variable. No other response will carry it.
- Header and tags carry it. The server writes the nonce into the policy header and into the nonce attribute of each script tag that it wrote itself. It sends the header and the page together.
- A flaw adds a tag. The page also contains a script tag that nobody wrote on purpose, put there by an injection flaw. It has no nonce, because whoever used the flaw could not know a value that is made fresh for each response.
- The browser compares. For each script tag the browser compares its nonce attribute with the nonce in the policy. The server's own tags match, so they run.
- The injected tag is blocked. The injected tag has no nonce, so the browser refuses it, and a reporting directive sends a report. The flaw is still in the page, but its script does not run.
- The next response. The next response gets a new nonce. A value that someone learned from one page is worth nothing on the next, because the server never uses it again.
The flaw that added the tag is still in the page after the last step, and the tag does nothing. That is the whole promise of a policy that holds, and the reason it is a second layer, never a replacement for encoding the output.
Checkpoint
Check: What makes a policy hold
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a Content Security Policy, and what does a policy that holds mean?
A response header that tells the browser which scripts, frames, forms and other resources a page may use; one that holds lets only the scripts the server marked run. Google's study of 1,680,867 hosts that sent a policy found that 99.34 % gave no real protection against XSS, and 75.81 % of the unique policies relied on script allowlists that can be bypassed.
-
What does CSP cover, and what is it not?
Scripts (script-src and relatives), clickjacking (frame-ancestors), plain-HTTP subresources (upgrade-insecure-requests) and DOM sinks (Trusted Types). It is defence in depth: when output encoding fails, it restricts what an injected script is able to do. It does nothing about the flaw itself or about server-side flaws such as SQL injection, and it should not be the only defence against XSS.
-
What three sources make a script runnable under a strict CSP?
A nonce: a random value generated afresh for each response, which appears both in the header and on every script tag the server trusts; a hash source, the SHA-256 hash of a script's exact text; and 'strict-dynamic', which lets a trusted script add further scripts while the browser ignores host lists.
-
Why does a host-list policy fail, and why is 'unsafe-inline' no repair?
In the lesson a host list let in an injected tag pointing at a file on the allowed CDN while blocking the page's own inline script and the file its loader adds. Adding 'unsafe-inline' let in three scripts nobody meant to run, including the injected inline script and an onclick attribute. A nonce policy with 'strict-dynamic' judged all seven scripts correctly, and Report-Only mode lets such a policy reach a real site by reporting violations without blocking.
Build the strict policy directive by directive, and audit it Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Use nonces on dynamic pages and hashes on static ones
- Roll out in report-only mode and triage the reports by cause
- Patterns that quietly defeat a policy, and what replaces them
- Practise: a policy for three pages
- A Content Security Policy for three pages
- Interview questions
- What does a Content Security Policy do, and what does it not do?
- Your app sends a policy with a CDN host, 'self' and 'unsafe-inline' in script-src. The security team wants a strict policy without an outage. Describe the migration and the first things that break.
- A static marketing site on a CDN has four inline scripts and wants a policy without 'unsafe-inline'. Nonces are not possible. What do you do, and what will surprise the team?
- A review finds a policy with a wildcard CDN host in script-src and no object-src or base-uri. The team says CSP is done because cross-site scripting reports fell to zero. What do you say, and what do you change?
- Key takeaways
1 diagram · 3 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 35 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- Content Security Policy
- A response header that tells the browser which scripts, frames, forms and other resources a page may use, so that an injected script can be refused. It limits the damage of a flaw and does not remove the flaw.
- hash source
- A script-src entry such as 'sha256-' followed by a base64 value, which names a script by the SHA-256 hash of its exact text. Any change to the text, even a space, gives another hash and blocks the script.
- nonce
- A random value, new for every response, that the server writes in the Content-Security-Policy header and in the nonce attribute of each script it trusts. The browser runs only the script tags whose nonce matches.
- Report-Only mode
- Sending a policy in the Content-Security-Policy-Report-Only header, so that violations are reported and nothing is blocked. It shows what an enforced policy would break before it is enforced.
References
- Content Security Policy (CSP) (MDN Web Docs (Mozilla))
- Content-Security-Policy-Report-Only header (MDN Web Docs (Mozilla))
- Content-Security-Policy script-src directive (MDN Web Docs (Mozilla))
- Content-Security-Policy report-uri directive (MDN Web Docs (Mozilla))
- Content-Security-Policy upgrade-insecure-requests directive (MDN Web Docs (Mozilla))
- CSP violation reports (the Reporting API body) (MDN Web Docs (Mozilla))
- Content Security Policy Level 3 (W3C Working Draft) (W3C)
- Content Security Policy Cheat Sheet (OWASP Foundation)
- ASVS 5.0.0, V3 Web Frontend Security (requirements 3.4.3, 3.4.6 and 3.4.7) (OWASP Foundation)
- CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy (ACM CCS 2016) (Google Research)
- Helmet documentation (Content-Security-Policy defaults and nonces) (Helmet (helmetjs))
- MDN browser-compat-data (Content-Security-Policy directives) (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress