KB — Building — Content Security Policy
Content Security Policy researched
Script-src is the whole game in CSP hardening. Build the right policy for your platform — domain allowlist for pragmatic baseline, nonce + strict-dynamic for real XSS protection.
Use this when
Hardening a client site against script injection after an Aikido or similar finding, launching a new HubSpot or Webflow site with third-party scripts, or remediating an unsafe CSP before going live.
Definition of done
The CSP is deployed in Report-Only mode with a live violation-reporting endpoint. A report feed shows zero (or expected, minor) violations on golden-path pages. The policy is then enforced, and you monitor for 48–72 hours before declaring success.
The three hardening approaches
CSP's protection hinges on script-src. No script-src (or a default-src fallback missing both) means scripts run unrestricted, even if other directives look good. upgrade-insecure-requests, for instance, doesn't constrain scripts at all.
Three ways to constrain script-src, ranked by protection:
| Approach | Syntax | Strength | Best for |
|---|---|---|---|
| Domain allowlist | script-src 'self' *.hubspot.com cdn.example.com |
Weak. JSONP gadgets or script-injection on allowlisted hosts bypass it. Inline is blocked, which is the main win. | Platform-hosted sites (HubSpot, Webflow) where you can't inject per-request nonces. Pragmatic, common baseline. |
Nonce + strict-dynamic |
script-src 'nonce-RANDOM' 'strict-dynamic' |
Strong. OWASP/Google recommended. Only scripts carrying the per-request nonce run; trusted scripts may load further scripts without being allowlisted. | Sites where you control HTML rendering or can inject at the edge (Cloudflare Workers). |
| Hashes | script-src 'sha256-BASE64HASH' |
Strong for fixed inline scripts; brittle if content changes frequently. | A known, fixed set of inline scripts that don't change per request. |
Nonce + strict-dynamic mechanics
A nonce is a random value regenerated per request, placed both in the CSP header and on each trusted <script> tag. Server generates it fresh for every response; attacker sees only that one response's nonce and cannot reuse it. Deploying a static nonce is security theater — it offers zero protection.
The 'strict-dynamic' keyword extends trust from a nonced script to scripts it creates dynamically (via document.createElement('script')), so you don't have to allowlist every CDN a trusted loader pulls from. This simplifies the policy dramatically.
Browser support: 'strict-dynamic' in Chrome 52+, Edge 79+, Firefox 52+, Safari 15.4+ (verified 2026-05-28).
Graceful degradation — why 'unsafe-inline' alongside a nonce is safe
Modern policy stacks fallbacks for older browsers:
script-src 'nonce-abc123' 'strict-dynamic' 'unsafe-inline' https:
How browsers parse this:
- CSP3 browsers (Chrome, Firefox, modern Safari, Edge): honor the nonce +
'strict-dynamic', ignore'unsafe-inline'andhttps:. Full protection. - CSP2 browsers (older Safari, IE11): honor the nonce and
https:, ignore'strict-dynamic'. Still protected. - CSP1 browsers (very old): fall back to
'unsafe-inline' https:(no nonce/hash checking). Site works, but no script injection protection.
So adding 'unsafe-inline' *alongside* a nonce is safe and recommended for back-compat — modern browsers ignore it. This is the opposite of the naive "unsafe-inline = bad" reading.
Cloudflare edge-nonce pattern
If your site sits behind Cloudflare, two paths to CSP:
- Transform Rules → Modify Response Header: sets a static header value. Good for domain allowlists; cannot generate a per-request nonce. Simplest, no compute.
- Cloudflare Workers +
HTMLRewriter: generates a fresh nonce per request using Web Crypto, injectsnonce="…"into every<script>viaHTMLRewriter.setAttribute(), and sets the matching CSP header — without changing the origin. This is how you get nonce +strict-dynamicon a site whose origin (HubSpot) you don't control.
Critical: an edge-set CSP overrides whatever the origin emits. Pick one owner of the header. If you set CSP at Cloudflare, don't also rely on HubSpot's native nonce feature — the edge header wins and has no nonce in it.
HubSpot-specific gotchas
HubSpot CMS has a native nonce feature — when enabled, HubSpot auto-generates a per-request nonce for HubSpot-hosted and HubSpot-injected scripts. But the feature has real limits:
- Nonces apply to
<script>tags and<style>tags only, not attributes. Anonload=event on a<link>(e.g., async CSS viarequire_css(async: true)) is inline JS and gets blocked. Workaround: load CSS viarequire_jsor a JS loader instead ofonload. - Custom module inline scripts sometimes still get blocked after enabling nonce — HubSpot's nonce injection doesn't always reach custom-coded inline blocks.
- Embedded forms and HubSpot widgets pull additional domains — you must allowlist them in
script-srcregardless of nonce.
HubSpot's nonce feature only helps if HubSpot emits the CSP header. If an edge (Cloudflare) sets/overrides the header, the nonce in HubSpot's HTML won't match the edge header unless the edge generates it. See the Cloudflare section above.
Reporting — where violations go
Modern CSP has two reporting directives:
report-to(CSP3, preferred): points to aReporting-Endpointsresponse header that names a collection endpoint (e.g.,Reporting-Endpoints: csp-endpoint="https://report.example.com/csp").report-uri(deprecated, CSP2): direct URI for violation reports. Browsers supportingreport-toignorereport-uri; for coverage during transition, specify both.
Options: managed (report-uri.com, Sentry, CentralCSP) or self-hosted (csplogger, a small POST endpoint logging application/csp-report JSON). Without a live reports feed, a Report-Only phase tells you nothing. Confirm the endpoint is actually ingesting before counting the monitoring window.
Decision tree
Is the site platform-hosted (HubSpot or Webflow) with many third-party scripts? A domain-allowlist script-src without 'unsafe-inline' is the pragmatic baseline and clears scanner findings. Accept it as "good," not "ideal."
Does the client need real XSS hardening / strong security posture? Move to nonce + strict-dynamic. Choose the header owner per platform: let HubSpot own it (enable its native nonce, no edge CSP), or own it at Cloudflare (use a Worker for nonce injection). Budget for breakage in the HubSpot gotchas above.
Always run Report-Only first with a live reports endpoint, and keep style-src 'unsafe-inline' as an accepted lower-risk exception on themed CMS sites.
Gotchas
Static nonces are worthless. If the nonce is the same for every request, an attacker reads it from the HTML and injects a matching script. Per-request generation is mandatory.
upgrade-insecure-requests is not a script policy. It only rewrites HTTP sub-resource URLs to HTTPS. It says nothing about what scripts may execute. Don't confuse it with script-src.
CSP is a backup, not a substitute for encoding/sanitization. WAFs do not catch DOM-based XSS. Code-review custom JavaScript regardless; CSP catches only inline injection.
Avoid allowlist CSPs if you can. Domain allowlists are easily bypassed (JSONP gadgets, script-injection on allowlisted hosts) and bloat fast — Google Analytics alone can require ~187 allowlisted domains. Strict nonce/hash beats allowlist if your platform supports it.
Why & sources
CSP is one line of defense in a layered XSS strategy. Full cited research: Web security for client sites. This page synthesizes the latest guidance from OWASP CSP Cheat Sheet, web.dev Strict CSP, MDN script-src, and Cloudflare's CSP documentation (verified 2026-05-28). Cross-link the security baseline page for the broader XSS defense picture.