At 11:42 PM on a Thursday, our marketing page started shipping a crypto wallet script that nobody had approved.
No server was hacked. No deploy happened. A third-party snippet changed upstream, and because we had a loose allowlist CSP, the browser happily executed it. The incident ended fast, but the postmortem was uncomfortable: we had been treating Content-Security-Policy-Report-Only as proof of safety, not as a migration tool.
This guide is for teams in that exact spot. If your frontend has legacy inline scripts, a few unavoidable third-party tags, and a backlog full of “security hardening later,” you can still roll out a strict CSP rollout and a practical Trusted Types migration without freezing delivery.
Why “CSP is enabled” is often a false sense of security
According to MDN and OWASP guidance, CSP only helps when policy shape is strict enough to block script injection paths, not just catalog trusted domains. A host allowlist such as script-src cdn1.example.com cdn2.example.com can still be bypassed in common real-world setups. That is why current guidance leans toward nonce- or hash-based strict CSP with 'strict-dynamic', plus hard restrictions like object-src 'none' and base-uri 'none'.
The second gap is client-side injection. Even with better server templates, DOM XSS often survives in app code via innerHTML, insertAdjacentHTML, document.write, or unsafe script URL construction. Trusted Types closes this gap by requiring typed values at dangerous sinks when require-trusted-types-for 'script' is enabled.
In short, CSP report-only is the rehearsal, not opening night.
A rollout pattern that works under release pressure
Phase 1: Inventory and reporting first
Start with report-only headers in production traffic. Collect violations for at least one full business cycle (including weekends, scheduled jobs, and campaign traffic). Keep payload samples small and avoid sensitive body data. If your report pipeline is noisy, normalize directives and group by endpoint + effective directive + source file.
Operationally, treat this like any reliability stream. If you already run event pipelines, patterns from our webhook reliability write-up can help you avoid duplicate report storms and dropped violations: idempotent event ingestion.
// Express example: strict policy in report-only mode first
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
const csp = [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`object-src 'none'`,
`base-uri 'none'`,
`frame-ancestors 'none'`,
`report-uri /csp-report` // keep for broad compatibility
].join("; ");
res.setHeader("Content-Security-Policy-Report-Only", csp);
next();
});
app.post("/csp-report", express.json({ type: ["application/csp-report", "application/reports+json", "application/json"] }),
(req, res) => {
// redact, dedupe, and enqueue for analysis
res.status(204).end();
}
);
Phase 2: Remove fragile patterns before enforcement
Common breakpoints are inline event handlers (onclick), legacy templating helpers, and tag managers injecting unsafely ordered scripts. Move behavior into external modules, bind events via JavaScript, and attach nonce attributes server-side per response.
For governance, enforce this in CI the same way you enforce build integrity. A lightweight gate in your pipeline keeps regressions from reintroducing unsafe inline code, similar to our approach in GitHub Actions pipeline hardening.
Phase 3: Migrate DOM sinks with Trusted Types
Use report-only first for Trusted Types too. Then migrate hotspots to safe APIs (textContent, DOM node creation) or sanitization libraries that can return trusted values.
// Trusted Types + DOMPurify pattern
import DOMPurify from "dompurify";
const policy = window.trustedTypes?.createPolicy("app-html", {
createHTML: (input) => DOMPurify.sanitize(input, {
RETURN_TRUSTED_TYPE: true
})
});
export function renderComment(el, untrustedHtml) {
// Preferred: avoid HTML sinks when possible
// el.textContent = untrustedHtml;
if (!policy) {
// fallback for browsers without Trusted Types support
el.innerHTML = DOMPurify.sanitize(untrustedHtml);
return;
}
el.innerHTML = policy.createHTML(untrustedHtml);
}
// Enforcement header once clean:
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html;
Tradeoffs you should decide explicitly
- Nonce vs hash: nonces are great for server-rendered responses; hashes are often easier for static pages but can become brittle when inline bundles change frequently.
- Third-party tags: strict CSP improves safety, but some ad/analytics tools need redesign or isolation before enforcement.
- Backward compatibility: keep
report-uriduring migration because support for newer reporting directives is uneven across browser versions. - Team velocity: early migration adds friction, but long-term review cost drops because dangerous sinks become visible and testable.
If your organization already uses policy-as-code for infrastructure decisions, mirror that model for frontend security controls too. The same policy mindset from Kubernetes admission control applies surprisingly well here.
Troubleshooting: what usually breaks first
1) Scripts suddenly stop loading in production
Check nonce propagation end-to-end. A frequent bug is generating one nonce in middleware and rendering a different value in the template fragment cache.
2) Violation volume explodes after launch
You are likely collecting duplicates from retries, SPA route changes, and browser extensions. Dedupe by tuple: effectiveDirective + blockedURI + sourceFile + lineNumber, then sample aggressively.
3) Trusted Types blocks old UI widgets
Legacy editor plugins often write directly to innerHTML. Wrap those paths behind a narrow policy and schedule replacement, instead of broad “default” policies that silently re-open risk.
4) Security team wants enforcement, product team fears outages
Run two policies briefly: a looser enforced CSP plus a stricter report-only CSP. Promote directives one by one, using incident-style change windows and rollback criteria. This change-management discipline pairs well with a wider operational hardening culture: practical security operations guidance.
What to measure after enforcement
After you switch from report-only to enforce mode, track three things for at least two release cycles: blocked script attempts per 1,000 page views, frontend error budget impact, and mean time to resolve CSP regressions. You want security signal to go up while user-visible breakage stays flat. If breakage climbs, inspect directive-level deltas rather than rolling everything back. Small targeted policy changes are usually safer than a full disable. Think of this as the same disciplined feedback loop used in production reliability work.
FAQ
Do we still need output encoding and framework escaping if we use strict CSP?
Yes. CSP is defense in depth, not a replacement for secure coding. Keep contextual escaping and safe templating as your first line.
Can we deploy Trusted Types without rewriting the whole app?
Usually yes. Start with report-only, fix top violating sinks, and migrate module by module. Most teams can stage this over normal release cycles.
Should we remove all third-party scripts to make CSP easier?
Not always. Keep high-value integrations, but isolate and review them. The goal is controlled execution, not ideological purity.
Actionable takeaways
- Adopt a strict nonce- or hash-based CSP shape first, then tune directives from real reports.
- Use report-only for both CSP and Trusted Types before enforcement, with a deduplicated reporting pipeline.
- Eliminate inline handlers and dangerous DOM sinks in priority order, focusing on high-traffic routes first.
- Document explicit rollout tradeoffs (compatibility, partner scripts, rollback signals) before switching to enforce mode.
- Add CI checks so unsafe patterns do not quietly return in later releases.
A careful strict CSP rollout is not glamorous work, but it is one of those rare security investments that reduces both incident risk and long-term engineering noise. Once Trusted Types is in enforce mode, future DOM XSS reviews become dramatically simpler because the browser is now helping you say “no” by default.

Leave a Reply