Topic: Web platform
Don't block first: use CSP Report-Only to find real dependencies, then tighten safely
CSP Report-Only is not a pause in security. It reveals real dependencies, noise, and legacy code patterns before a nonce-, hash-, and least-privilege policy reaches production.
The riskiest first move when adding Content Security Policy (CSP) is usually not missing one source. It is seeing a stream of violations and pasting every hostname into an allowlist. The page stops breaking, but the policy can become too broad to provide much protection.
CSP limits what a page can load and execute. It is a defense-in-depth layer alongside output encoding and sanitization, not a magic replacement for fixing XSS. To make that layer useful, treat Report-Only as an observation period, not a one-time compatibility switch.
Report-Only does not block requests, but it makes a draft policy observable
Content-Security-Policy-Report-Only is an HTTP response header. The browser reports resources that a draft policy would block, without actually blocking them. That makes it possible to see production dependencies before deciding what to change. It cannot be set in a <meta> element.
Here is an abstract starting point:
Reporting-Endpoints: csp="https://reports.example.com/csp"
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
report-to csp;
report-uri /csp-report;
This is not a copy-paste production configuration. {random} must be regenerated for every response, and endpoints, browser support, and real resources must fit the system. The important connection is that report-to names an endpoint in Reporting-Endpoints; for cross-browser coverage, MDN still recommends sending the older report-uri as well.
Animated meme (expand/collapse)
The value of Report-Only is more than avoiding breakage. It makes us ask: which feature loaded this script, style, image, or frame? Do we own it? Is it still needed? One hostname can be a required payment component, an old tag, an experiment left behind, or a third-party loader that should not be open to the whole site.
Do not turn reports directly into an allowlist
I find it useful to send each kind of violation down one of three paths:
- Our code: fix the bundle, template, or inline pattern so it fits the intended policy.
- A necessary external service: confirm the provider, purpose, and loading chain, then allow only the needed directive and source.
- An item with no clear owner: inspect the page and environment that produced it; do not approve it without enough evidence.
That classification matters more than whether the number of reports falls. CSP allowlists tend to grow with third-party scripts. MDN specifically warns that overly broad source lists can allow unsafe domains and defeat the purpose of CSP. Buttondown’s production rollout shows the practical shape of the work: collect violations in Report-Only, then decide whether to remove a load or actually allow its source instead of gambling on turning enforcement on all at once.
Tighten script-src by changing code shape first
An effective CSP is not simply a longer list of domains after script-src. A strict CSP starts from identifiable code:
- A nonce fits dynamic responses. The server generates an unpredictable fresh value for each response, and the header and permitted
<script>elements share it. - A hash fits a fixed inline script. It names that exact content, so even a whitespace change can make it stop matching.
strict-dynamiclets a nonce- or hash-trusted root script load follow-on scripts in supporting browsers. It does not make arbitrary scripts trusted.
So when a report finds onclick="…", string-form setTimeout(), or eval(), do not first open the gate with unsafe-inline or unsafe-eval. The former broadly permits inline script; the latter permits treating strings as JavaScript. Moving event handlers to addEventListener() and keeping program data as data is usually the safer durable fix.
Animated meme (expand/collapse)
When an exception is unavoidable, a narrower option may exist, such as unsafe-hashes for a specific inline event handler. It is still a trade-off, not a default. Record an owner, reason, and removal condition for every exception. Otherwise a policy quietly turns from a security rule into a historical list nobody can safely touch.
A release sequence that can be followed in practice
I split a CSP rollout into reversible steps:
- Draft the target policy, including explicit boundaries such as
object-src 'none'andbase-uri 'none'. - Enable Report-Only through a response header in real traffic, and collect traceable reports.
- Fix our code, review external services, and reject sources that cannot be explained.
- After checking representative pages and flows, enable the same narrowed policy with
Content-Security-Policy. - Keep observing, so a later third-party or template change cannot quietly pierce the boundary.
Step 4 is the block. The first three steps avoid making normal users the test harness. A good CSP is not a very long domain list. It is an execution contract that explains why every executable source exists, who owns it, and when it should be removed.
What I learned
- I can use Report-Only to see real dependencies before an enforcing policy affects users; its reports are review input, not an automatic allowlist.
- Nonces, hashes, and
strict-dynamiccreate a traceable trust chain instead of an ever-growing source list. unsafe-inlineandunsafe-evalpoint to code-shape problems. When a refactor is possible, it is usually safer than a permanent exception.- CSP is one more layer against XSS. It does not replace output encoding, sanitization, or ordinary security fixes.