Content Security Policy, directive by directive 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. 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 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 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 () 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 . 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