SecurityGENERALBROWSER-SPECIFICSPEC-EVOLVINGPLATFORM-SPECIFIC

Content Security Policy

A browser-enforced allowlist for what your page may execute and load. It caps the damage of an injection you missed — and unsafe-inline in the script directive turns the whole thing off.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What can a Content Security Policy actually stop, and how do I ship one without breaking the application?

The user intent

A person uses a page and expects the code running in it to be the code the product intended, not something a comment field contributed.

The obvious build

Add a Content-Security-Policy header with a sensible-looking list of sources. The security checklist item is satisfied and nothing visibly changed.

Why it breaks

Nothing visibly changing is usually the problem. A policy containing unsafe-inline in script-src permits exactly the thing an injection needs, so the header exists and the defence does not (Cross-Site Scripting).

How it breaks in a real browser
  • Nothing visibly changing is usually the problem. A policy containing unsafe-inline in script-src permits exactly the thing an injection needs, so the header exists and the defence does not (Cross-Site Scripting).
  • A policy that does bite tends to bite in production, on the pages nobody tested, breaking a vendor tag or an analytics snippet that only loads for real users (Third-Party Scripts and the Supply Chain).
  • Allowlisting a CDN host wholesale can be close to allowlisting anything, because a host that serves arbitrary user-uploaded or user-configurable scripts is a source of arbitrary scripts.
  • Delivering the policy in a meta element covers only what the parser reaches after it, and cannot express some directives at all — so a policy that looks right can have a hole at the top of the document (Tree Construction).
  • Reports arrive from browser extensions and injected assistive tooling as well as from your own code, so an unfiltered report stream looks like a breach and is mostly noise.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A policy is a set of directives, each naming a resource type and the sources permitted for it: script-src, style-src, img-src, connect-src, font-src, frame-src, frame-ancestors, form-action, base-uri, and default-src as the fallback for the fetch directives.
  • The browser evaluates each load and each execution against the matching directive. A violation is blocked and reported; the page continues with that resource missing.
  • Inline script is the crux. By default a policy blocks all inline script — <script> bodies, on* attributes and javascript: URLs — which is exactly the payload class an injection produces. unsafe-inline restores all of it.
  • To keep legitimate inline script, use a nonce or a hash. A nonce is a per-response random value placed in the header and repeated on each permitted <script nonce>; a hash is a digest of the script body listed in the policy.
  • A nonce is only a defence if it is unpredictable and per-response. A nonce baked into a statically cached page is a public constant, and the policy then permits any injected script that copies it (Content-Hashed Assets).
  • strict-dynamic changes the model: scripts you explicitly trusted with a nonce or hash may load further scripts, and host allowlists are ignored. It is what makes a strict policy survivable in an application with a real dependency graph.
  • Report-only modeContent-Security-Policy-Report-Only — evaluates and reports without blocking, and both headers can be sent at once so you can enforce one policy while testing a stricter one.
  • Reporting is delivered to an endpoint you nominate, and the reporting mechanism itself has been through more than one generation, so the directive to use depends on what your browsers support.

What this makes the browser do

And which of it is avoidable.

  • Parsing the policy once per document, then matching every subresource URL and every script execution against it — cheap individually, and real on a page with thousands of subresources or a very long policy.
  • Computing hashes for inline scripts and styles when hash sources are in play, which is per-script work at parse time (Why a Script Tag Stops the Parser).
  • Generating and sending violation reports, which are real network requests. A policy fighting an extension on every page view produces a report per view per user (Network Failures Only the Client Can See).
  • Nonce propagation forces the HTML to be dynamic per response, which removes the option of caching the document at the edge and is a genuine architectural cost (CDN Delivery).
  • Avoidable work: a policy with many redundant host entries accumulated over years, each one matched against every load.

The difference between a policy and a defence

Two headers, both of which pass an audit. The first is what most applications ship and it permits precisely the thing an injection needs. The second is what a policy is for. Reading them side by side is the fastest way to see why "we have a CSP" is not an answer to "what happens if something is injected".

The single most important token in either header is unsafe-inline inside script-src. With it, injected inline script executes normally and the entire script directive is decorative. A nonce replaces it: the browser runs inline script only when it carries the value the policy named for that response.

Two policies, one of which does something
1# DECORATIVE — ships everywhere, blocks nothing that matters
2Content-Security-Policy:
3 default-src 'self' https:;
4 script-src 'self' https: 'unsafe-inline' 'unsafe-eval';
5 style-src 'self' https: 'unsafe-inline';
6 img-src *
7# 'unsafe-inline' in script-src permits injected inline script.
8# 'unsafe-eval' keeps the eval sinks open.
9# https: as a source permits every https host on the internet.
10
11# LOAD-BEARING — nonce per response, no inline escape hatch
12Content-Security-Policy:
13 default-src 'none';
14 script-src 'nonce-r4nd0mPerResponse' 'strict-dynamic';
15 style-src 'self';
16 img-src 'self' data:;
17 font-src 'self';
18 connect-src 'self' https://api.example.com;
19 form-action 'self';
20 frame-ancestors 'none';
21 base-uri 'self';
22 object-src 'none';
23 report-to csp-endpoint
24
25# And in the HTML, on each script you actually intended to run:
26# <script nonce="r4nd0mPerResponse" src="/app.js"></script>

The nonce must be freshly generated per response and unguessable. A nonce that ships inside a cached HTML document is a public constant, and the policy then permits any injected script that copies it out of the page.

Nonce, hash, or host allowlist

All three are legitimate and they make different demands on your delivery architecture. The choice is usually forced by whether your HTML is generated per response, which is why this question belongs with the rendering strategy discussion rather than with the security review (Choosing a Rendering Strategy).

How should `script-src` name what is allowed?

Is the HTML generated per response, and how much third-party script is in the page?

Nonce plus `strict-dynamic`

when HTML is generated per response — server-rendered, edge-rendered, or templated at the CDN. The strongest practical option.

cost The document cannot be cached as a static file, and every inline script and every dynamically injected loader has to carry or inherit the nonce (Server-Side Rendering).

Hashes of inline scripts

when A static document you want to keep cacheable, with a small and stable set of inline scripts.

cost Every inline script change is a policy change, so build and header generation become coupled (Content-Hashed Assets).

Host allowlist

when A transitional step, or a page whose script sources are few, stable and tightly controlled.

cost Only as strong as the weakest allowlisted host, ages badly as vendors change domains, and says nothing about inline script (Third-Party Scripts and the Supply Chain).

`unsafe-inline` with a nonce present

when A deliberate compatibility bridge: browsers that understand nonces ignore unsafe-inline, older ones do not.

cost You are relying on nonce support being universal in your traffic. Verify that rather than assuming it, and plan to remove the fallback.

Shipping one without an outage

A policy rollout fails in a specific way: it works locally, passes staging, and breaks a payment widget for a subset of users on a browser nobody has. The sequence below exists to move that discovery earlier, and the step people skip is the third one — reading the reports long enough to see the traffic that only appears at scale.

From no policy to an enforced strict one
  1. 1
    Inventory

    List every script source, inline script, style source, connect target and frame in the real page — including the ones a tag manager adds at runtime.

    fails by Being done from the codebase rather than from a live page, which misses everything injected by vendors (Third-Party Scripts and the Supply Chain).

  2. 2
    Report-only, permissive

    Ship a policy close to current behaviour in report-only mode, wired to an endpoint that can absorb the volume.

    fails by A reporting endpoint that cannot take the traffic, so the data you needed is the data you dropped.

  3. 3
    Observe real traffic

    Collect for long enough to cover weekly patterns, all your browsers, extensions and every marketing campaign that injects something.

    fails by Being cut short. This is the step that gets compressed, and it is the one that prevents the outage.

  4. 4
    Triage by source

    Separate your code, your vendors, and browser extensions. Only the first two are actionable, and the third is usually the majority.

    fails by Treating extension noise as attacks, then treating all reports as noise a week later (Frontend Error Tracking).

  5. 5
    Tighten in report-only

    Move toward nonce plus strict-dynamic, remove unsafe-eval, narrow connect-src. Keep the permissive policy enforced meanwhile.

    fails by Skipping straight to enforcement because the reports looked quiet on a Tuesday.

  6. 6
    Enforce, then keep watching

    Promote the strict policy to the enforcing header and keep a stricter one in report-only behind it.

    fails by Relaxing during an incident and never restoring — assert on unsafe-inline in the build so the regression is visible (Choosing the Test Level).

The pattern that makes this safe is running both headers at once: enforce what you know works, and report-only what you want next.

How to build it

Most important first.

  • Escape first. A policy is the second layer; treating it as the fix means shipping the vulnerability and relying on a mitigation you have not verified (Sanitization and Trusted HTML).
  • Aim for a nonce-based script-src with strict-dynamic and no unsafe-inline. Host allowlists age badly and are hard to reason about; a nonce is a property of a response you control.
  • Deliver the policy as a response header, not a meta element, so it applies to the whole document and can express every directive (Deploying a Frontend).
  • Roll out in report-only first, for long enough to see real traffic including the browsers, extensions and vendor tags you do not have locally.
  • Set object-src 'none' and base-uri 'self' early — both are cheap, rarely break anything, and close real injection paths.
  • Add frame-ancestors for framing control in the same policy rather than as a separate concern (Clickjacking and Framing).
  • Triage reports by source before acting. Extension-origin and assistive-tooling violations are common and are not attacks (Frontend Error Tracking).
  • Version the policy with the deploy and expect long-lived tabs to be running the previous one for a while (Long-Lived Clients and Version Skew).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This is the accessibility risk of CSP and it is under-discussed: some assistive technology, translation tooling and reading extensions work by injecting script or inline style into the page, and a strict policy blocks them. The user gets a page that does not work with their tools and no explanation (Accessibility Testing).
  • Blocking inline style can break user-installed high-contrast or large-text overrides, which are an accessibility mechanism for the people who need them most (Contrast, Colour and Motion).
  • Include extension-origin schemes where your browsers support that, and test the policy with common assistive tooling enabled rather than only in a clean profile.
  • When a policy blocks a functional part of the page, the resulting gap needs a real error state that is announced — an empty region tells a screen-reader user nothing (Live Regions and Announcement).
  • A blocked font changes metrics and can reduce legibility for dyslexic readers or anyone relying on a specific typeface; treat font fallbacks as an accessibility property, not a cosmetic one (Responsive Typography).

What can go wrong

Failure modes
  • A policy with unsafe-inline in script-src, which is the most common shipped configuration and the one that provides the least.
  • unsafe-eval retained because a dependency needs it — often a templating library or an older date or validation package — which keeps a code sink open for the whole application (Bundle Analysis).
  • A static nonce, or a nonce reused across cached responses, which turns the mechanism into a public constant.
  • A host allowlist including a CDN that will serve any script anyone uploads, making the allowlist decorative.
  • A blocked style or font producing an unstyled or mis-measured layout rather than an error, so the failure looks like a CSS bug (Visual Stability).
  • The mitigation failing: a report endpoint that goes down under its own volume, or a policy relaxed during an incident and never restored.
What can arrive out of order
  • A policy delivered by meta applies only from its position onward, so markup parsed before it — including anything injected into the head — was never evaluated (Streaming HTML).
  • A tightened policy and a cached document race: users on a cached HTML response keep the old policy until the document is refetched (Content-Hashed Assets).
  • A nonce is bound to a response; any flow that reuses HTML across responses — a bfcache restore, a service worker replay, a prerendered document — needs checking that the nonce is still the one the policy names (Intercepting Fetch).
Security
  • The browser enforces this absolutely: a blocked script does not run, and no application code can override the policy the document was served with.
  • It is a mitigation. It reduces what an injection can do; it does not prevent the injection, and a policy is not a reason to skip escaping (Cross-Site Scripting).
  • It also constrains exfiltration: connect-src, img-src and form-action limit where data can be sent, which is often the more valuable half in practice.
  • frame-ancestors is the modern control for framing and is strictly more expressive than the older header (Clickjacking and Framing).
  • A policy tells an attacker what is permitted, since it is a response header. That is accepted: the value is in enforcement, not in secrecy.
  • Bypass research — nonce reuse, dangling markup, JSONP endpoints on allowlisted hosts, gadget chains in permitted libraries — is Security Engineering territory; the frontend lesson is that a permissive policy is a policy that has already been bypassed (Content Security Policy).
Misreads
  • "We have a CSP, so XSS is handled." A policy caps impact. Escaping prevents the bug. Shipping only the first is a decision to rely on a mitigation.
  • "Any policy is better than none." A policy with unsafe-inline in script-src permits the payload class that matters, while producing an audit line that says the control exists.
  • "Allowlisting our CDN is equivalent to a nonce." Only if that CDN never serves attacker-influenced script, which is a much stronger claim than it sounds.
  • "Report-only mode protects us while we test." It reports. It blocks nothing.
  • "The meta tag is equivalent to the header." It applies from where the parser reaches it, and cannot express frame-ancestors or report-uri at all.
  • "Violations mean we are under attack." Most violations in a real report stream come from browser extensions and injected tooling (Frontend Error Tracking).

Measuring it, and what changes in the field

How you would see this
  • Report-only violations from real traffic, grouped by directive and by source, are the primary instrument. Grouping matters: one noisy extension will otherwise dominate the whole stream.
  • The console prints each violation with the directive and the blocked URI, which is the fast local loop (A Mental Model of the Devtools).
  • A count of inline scripts in your rendered HTML tells you how much work a nonce rollout actually is before you start.
  • Tracking unsafe-inline and unsafe-eval presence as a build-time assertion prevents the slow re-loosening that follows every incident (Choosing the Test Level).
Slow device, slow network, large data, old tab
  • On a page with many third-party tags, a strict policy is a coordination project across several vendors rather than a header change (Third-Party Scripts and the Supply Chain).
  • With server-side rendering, nonces are straightforward because the HTML is generated per response; with a fully static document they require either edge templating or a hash-based approach (Static Site Generation).
  • Under a service worker, a synthesised response carries whatever headers the worker set, so a policy can be lost or changed by your own caching layer (Intercepting Fetch).
  • In browsers with different reporting support, the same policy produces different report volumes and shapes, so absence of reports from one population is not evidence of absence of violations.
What this costs
  • A strict policy is real defence in depth and costs a rollout project, ongoing vendor coordination, and a class of production breakage that is hard to reproduce locally.
  • Nonces require per-response HTML, which trades away full-document edge caching; hashes keep the document cacheable and must be regenerated whenever an inline script changes (Browser HTTP Caching).
  • strict-dynamic makes a strict policy practical with real dependencies and delegates trust to whatever your trusted scripts choose to load (The Module Graph).
  • Report-only is safe and produces a period where you have the operational cost of a policy and none of its protection.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALDirective semantics, nonce and hash sources, strict-dynamic and report-only mode are specified and implemented across Blink, Gecko and WebKit; the enforcement decision is the same everywhere.
  • BROWSER-SPECIFICReporting differs meaningfully: the newer Reporting API is available in Chromium while other engines still rely on the older report directive, so the same policy yields different report volume and payload shape per browser and a per-browser blind spot.
  • SPEC-EVOLVINGThe reporting directives have already been through a deprecation cycle and the Sanitizer and Trusted Types integrations are still settling, so a policy written against last year's guidance may name a directive that is now ignored rather than one that is now enforced.
  • PLATFORM-SPECIFICWhether browser-extension origins are exempt from a page policy differs by platform and extension model, which is why assistive tooling can be blocked on one browser and work on another with an identical policy.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — treating a policy as a production configuration with a rollout, a rollback and an error budget, rather than as a one-time header change.