CSP Safe Rollout: Report-Only to Enforce in Six Stages
Deploy a Content-Security-Policy that actually stops XSS - without shipping a broken site.
Loading...
Deploy a Content-Security-Policy that actually stops XSS - without shipping a broken site.
CSP is a browser-enforced allowlist for every resource your HTML can pull in. When a page tries to load something that is not explicitly allowed, the browser refuses and (optionally) posts a violation report to an endpoint you control. Done properly, CSP turns a critical XSS - an attacker injecting <script> into your HTML - into a noisy console error that doesn't execute. Done sloppily (unsafe-inline, wildcard hosts), CSP is a compliance prop with none of the security it advertises.
Do not try to ship a strict CSP on day one. Real pages load more resources than anyone remembers, and a too-strict policy enforced on day one breaks the site. The working pattern is: learn what your site loads, formalise a policy, deploy as Report-Only, tighten, then flip to enforce.
Ship a Content-Security-Policy-Report-Only header with a deliberately permissive policy and a reporting endpoint. The goal is to discover what your pages actually load, including the long tail of marketing scripts, feature flags, and preview-only analytics.
Content-Security-Policy-Report-Only:
default-src *;
script-src * 'unsafe-inline' 'unsafe-eval';
style-src * 'unsafe-inline';
report-to csp-endpoint;
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://csp-reports.example.com/report"}]}This is deliberately open - the point is to learn. Run it for at least two weeks. Collect violation reports. Tag each violated source: 'ours' (we own it), 'vendor' (approved third party), 'unknown' (investigate).
From the reports, draft the real policy. Categorise every host into the directive it belongs in: script-src, style-src, img-src, font-src, connect-src, frame-src, media-src. Enumerate hosts explicitly - avoid wildcards if you can.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.example.com https://www.googletagmanager.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-to csp-endpoint;Generate a fresh random nonce on every HTML response (server-side). Emit it both in the CSP header and on every inline <script> you legitimately ship.
import crypto from 'node:crypto';
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy-Report-Only',
`default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https:;
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
report-to csp-endpoint;`.replace(/\s+/g, ' ').trim()
);
next();
});<!-- every inline <script> the server emits gets the nonce -->
<script nonce="<%= nonce %>">
window.__CONFIG__ = { featureX: true };
</script>strict-dynamic is the magic word: it tells the browser to trust scripts that an already-trusted (nonced or hashed) script loads. That means host allowlists become irrelevant for script-src - which is exactly what you want, because host allowlists are trivially bypassable.
Some inline snippets (third-party widgets, static analytics tags) cannot accept a dynamic nonce. For those, compute a SHA-256 of the exact script body and whitelist the hash.
echo -n "console.log('hello');" | openssl dgst -sha256 -binary | openssl base64
# → e.g. IiwQvCiDCWFZ0ypaHFj4W2bJ5b3pz0p6X8v1Yc2o3P0=script-src 'self' 'nonce-{{RANDOM}}' 'sha256-IiwQvCiDCWFZ0ypaHFj4W2bJ5b3pz0p6X8v1Yc2o3P0=' 'strict-dynamic';With every legitimate inline script either nonced or hashed, drop 'unsafe-inline' from script-src. Watch the violation reports for anything you missed. Fix or hash each one. Repeat for style-src once you have moved all inline styles to external stylesheets or to nonced <style> blocks.
Rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the same policy body. Keep reports on (you still want visibility). Watch error rates for 24 hours. If nothing breaks, you are done.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{{RANDOM}}' 'strict-dynamic';
style-src 'self' 'nonce-{{RANDOM}}';
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
report-to csp-endpoint;
upgrade-insecure-requests;Set frame-ancestors in CSP as the primary defense and X-Frame-Options: DENY as a fallback for legacy proxies. Modern browsers all prioritise frame-ancestors.
Occasionally - some legacy libraries need eval. If you cannot remove them, isolate them to a subdomain with its own lenient CSP, and keep the main origin eval-free. 'unsafe-eval' on the main origin is the cheapest bypass an attacker has.
Yes - it is valid to ship both Content-Security-Policy (the committed policy) and Content-Security-Policy-Report-Only (a tighter experimental policy). Use this when testing the next tightening step without risking enforcement.