# A Content Security Policy, read the way the browser reads it

> A CSP header is a sentence in a small formal language, and the browser reads it with an algorithm the specification spells out: split on semicolons, lower-case the names, ignore a directive that repeats, read every token of a value against a grammar. Most broken policies break at exactly those steps, and quietly: a keyword without quotes becomes a host name, a missing semicolon folds two directives into one, a default-src that looks protective governs nothing once script-src is set, 'unsafe-inline' beside a nonce is silently ignored. Here is CSP Level 3's own reading, step by step, with the fallback chain, the matching rules for wildcards, schemes and IP addresses, what 'strict-dynamic' switches off, and the three-directive policy the specification itself calls Strict CSP.

Source: https://ronutz.com/en/learn/content-security-policy-read-by-the-specification  
Updated: 2026-10-05  
Related tools: https://ronutz.com/en/tools/csp-evaluator, https://ronutz.com/en/tools/secure-headers, https://ronutz.com/en/tools/html-structure-explainer

---

[Content Security Policy, directive by directive](https://ronutz.com/en/learn/content-security-policy) explains what a CSP is for and what each directive buys. This article is about something narrower and, in practice, the cause of most policies that do less than their authors think: how the browser *reads* the header. Content Security Policy Level 3 (W3C Working Draft, 16 September 2026, read on 2026-10-05) does not leave that to intuition. It gives an algorithm for turning the header's text into a policy object, a grammar for every token, a fallback chain for every destination, and matching rules for every kind of source expression. Each step has a failure mode that is silent: the browser does not reject a policy it cannot make sense of, it applies the policy it did make sense of. The [CSP evaluator](https://ronutz.com/en/tools/csp-evaluator) performs the reading below and shows the result; this is the reading.

## Step one: semicolons, lower case, and the duplicate that vanishes

The algorithm "Parse a serialized CSP" (2.2.1) begins by "strictly splitting serialized on the U+003B SEMICOLON character". Each piece is stripped of whitespace; empty pieces are skipped; the directive name is "the result of collecting a sequence of code points from token which are not ASCII whitespace", then lower-cased, so "script-SRC 'none' and ScRiPt-sRc 'none' are equivalent". The rest of the piece, split on whitespace, is the value.

Then the step that bites: "If policy's directive set contains a directive whose name is directive name, continue." A second `script-src` later in the same policy is not merged and does not override; it is dropped, and the specification only asks that "the user agent SHOULD notify developers that a duplicate directive was ignored. A console warning might be appropriate". A policy assembled by two layers of configuration, each adding its own `script-src`, keeps whichever came first.

The semicolon rule has a twin failure. Write `script-src 'self' object-src 'none'` with the semicolon forgotten and there is one directive, `script-src`, whose value has four tokens. The third token, `object-src`, is read against the source-expression grammar and matches `host-source`: it is a server called object-src. The `object-src` directive that was meant to close the plugin sink never exists.

## Step two: every token against the grammar

A source list is "a whitespace-delimited series of source expressions", each one of five shapes (2.3.1): a `scheme-source` such as `https:`, a `host-source` such as `*.example.com` or `https://example.com:12/path/to/file.js`, a `keyword-source` such as `'self'` or `'unsafe-inline'`, a `nonce-source` such as `'nonce-ch4hvvbHDpv7xCSvXCs3BrNggHdTzxUA'`, and a `hash-source` such as `'sha256-abcd...'`. The keywords are quoted in the grammar itself: `keyword-source = "'self'" / "'unsafe-inline'" / "'unsafe-eval'" / "'strict-dynamic'" / "'unsafe-hashes'" / ...`. Written without the quotes, `self` is not a keyword that failed; it is a host-source that succeeded, a host named self, and `unsafe-inline` is a host named unsafe-inline. The policy parses, the console is quiet, and inline script is blocked while the author believes it was allowed, or `self` is not matched while the author believes it was.

Two more grammar facts explain common surprises. `'none'` is a production of its own: `serialized-source-list = ( source-expression *( required-ascii-whitespace source-expression ) ) / "'none'"`; beside other expressions it matches nothing and changes nothing. And "Though IP address do match the grammar above, only 127.0.0.1 will actually match a URL when used in a source expression", because host-part matching begins "If host is not a domain, return Does Not Match" (6.7.2.10). A policy that allows `10.0.0.1` allows nothing.

## Step three: the fallback chain, and the inheritance that is not there

`default-src` "serves as a fallback for the other fetch directives" (6.1.3), and the specification gives the exact order per destination in "Get fetch directive fallback list" (6.8.3): script elements look at `script-src-elem`, then `script-src`, then `default-src`; inline event handlers at `script-src-attr`, then `script-src`, then `default-src`; workers at `worker-src`, `child-src`, `script-src`, `default-src`; frames at `frame-src`, `child-src`, `default-src`; and `connect-src`, `img-src`, `font-src`, `media-src`, `manifest-src` and `object-src` fall back to `default-src` alone.

The sentence most often misread follows: "There is no inheritance. If a script-src directive is explicitly specified, for example, then the value of default-src has no influence on script requests." A policy that says `default-src 'none'; script-src https://cdn.example.com` does not mean "nothing, except that content delivery network (CDN) for scripts"; it means scripts from the CDN and from nowhere else, and for script the `'none'` is not consulted at all. Three directives never fall back: `base-uri`, `form-action` and `frame-ancestors`. The specification is explicit about the last: "a policy that declares default-src 'none' will still allow the resource to be embedded by anyone" (6.4.2). Clickjacking protection has to be written out.

## Step four: what matches what

The wildcard is narrower than it looks. For `*`, the matching algorithm (6.7.2.8) returns a match only if "url's scheme is an HTTP(S) scheme" or "url's scheme is the same as origin's scheme"; `data:` and `blob:` URLs are not matched by `*` unless written out, "e.g. default-src * data: custom-scheme-1: custom-scheme-2:". Schemes upgrade but never downgrade: "script-src http: is treated as equivalent to script-src http: https:" (6.7.2.9), and a host written without a scheme matches the page's scheme and its secure upgrade. `'self'` matches the page's origin and, within limits, its https and wss forms on the default or matching port; it never matches `blob:` (6.7.2.8).

Then the rule that decides most audits. "A source list allows all inline behavior of a given type if it contains the keyword-source expression 'unsafe-inline', and does not override that expression" (6.7.3.2), and the override is any nonce or hash in the same list, or, for script, `'strict-dynamic'`: "If expression matches the nonce-source or hash-source grammar, return Does Not Allow". So `script-src 'unsafe-inline' 'nonce-abc'` does not allow inline script in any browser that understands nonces; the keyword is a fallback for browsers that do not. The specification shows the intended ladder: the policy `'unsafe-inline' https: 'nonce-abcdefg' 'strict-dynamic'` "will act like 'unsafe-inline' https: in browsers that support CSP1, https: 'nonce-DhcnhD3khTMePgXwdayK9BsMqXjhguVV' in browsers that support CSP2, and 'nonce-DhcnhD3khTMePgXwdayK9BsMqXjhguVV' 'strict-dynamic' in browsers that support CSP3" (8.2).

## What 'strict-dynamic' switches off

"If present in a script-src or default-src directive, it has two main effects: host-source and scheme-source expressions, as well as the 'unsafe-inline' and 'self' keyword-sources will be ignored when loading script. hash-source and nonce-source expressions will be honored. Script requests which are triggered by non-'parser-inserted' script elements are allowed" (8.2). The first effect is why a host allowlist and `'strict-dynamic'` in the same list is not a contradiction but a ladder; it is also why `'strict-dynamic'` with no nonce and no hash leaves nothing that can run. The second effect is the point: a script the page trusted by nonce may load its own dependencies with `document.createElement('script')`, and those run because the trusted script created them, not because their host was listed. The specification is frank about why this exists: "Host- and path-based policies are tough to get right, especially on sprawling origins like CDNs", and the lists that result "end up being brittle, awkward, and difficult to implement and maintain".

## The policy the specification calls Strict CSP

Section 8.5 names the outcome of all of the above: "enforcing the following set of CSP directives has been identified as an effective and deployable mitigation against XSS. script-src: Only use nonce source-expression and/or hash source-expression with the 'strict-dynamic' keyword-source. ... base-uri: Specify a value of either 'self' or 'none'." Its two examples are the whole of it: `script-src 'strict-dynamic' 'nonce-{RANDOM}'; base-uri 'self';` and `script-src 'strict-dynamic' 'sha256-{HASHED_INLINE_SCRIPT}'; base-uri 'self';`. The nonce has its own rule: "the server MUST generate a unique value each time it transmits a policy. The generated value SHOULD be at least 128 bits long (before encoding), and SHOULD be generated via a cryptographically secure random number generator" (7.1), because "Nonces override the other restrictions present in the directive in which they're delivered." Add `object-src 'none'` to close the plugin sink the specification describes in 6.1.9, `frame-ancestors` in the header (it is one of the three directives a `meta` element cannot carry, 3.3), and `report-to` so that violations are heard, and the policy is as strong as this mechanism gets. Everything else in a header is read, by the browser, exactly as described above, and the evaluator reads it the same way.

## Sources

- [Content Security Policy Level 3, W3C Working Draft 16 September 2026](https://www.w3.org/TR/CSP3/) (read 2026-10-05): 2.2.1 Parse a serialized CSP; 2.3.1 Source Lists; 3.3 The meta element; 6.1.3 default-src; 6.4.2 frame-ancestors; 6.7.2.8 to 6.7.2.10 URL matching; 6.7.3.2 Does a source list allow all inline behavior; 6.8.3 Get fetch directive fallback list; 7.1 Nonce Reuse; 8.2 Usage of 'strict-dynamic'; 8.5 Strict CSP
- [Mixed Content, W3C Candidate Recommendation Draft 23 February 2023: 6.1 Strict Mixed Content Checking](https://www.w3.org/TR/mixed-content/#strict-checking) (read 2026-10-05)
- [Google csp-evaluator](https://github.com/google/csp-evaluator) (read 2026-10-05), the model for the evaluator's set of checks
