A Content Security Policy can be present, valid, and nearly useless.
That is why I do not treat “CSP header found” as a passing security test. The useful question is whether the browser refuses code that the application never intended to run.
CSP is a backup control for injection mistakes. It does not repair unsafe rendering, weak input handling, or a compromised trusted script. A good policy narrows the damage when one of those defenses fails.
This is the test I use before calling a policy real.
1. Capture every policy the browser receives
Start with the deployed response, not the config file you think produced it:
curl -sS -D - -o /dev/null https://example.com | grep -i content-security-policy
Look for both headers:
Content-Security-Policyis enforced.Content-Security-Policy-Report-Onlyobserves violations without blocking them.
A site can send more than one policy. Browsers enforce all of them, which means two individually reasonable policies can combine into something stricter than expected. Also check a few real routes. Middleware, a CDN rule, and an application route can produce different headers on the same origin.
If the policy exists only in a <meta http-equiv> element, move it to the HTTP response when you can. Header delivery supports the complete policy and applies before the browser works through the document.
2. Read script-src like a browser
script-src does most of the work against script injection. I flag these for review:
- Broad schemes such as
https: - Wildcard hosts
'unsafe-eval'- Long lists of third-party script origins
'unsafe-inline'without a nonce- or hash-based migration plan
There is an important wrinkle: 'unsafe-inline' is not always proof that a modern policy is broken. In a backward-compatible policy that also uses a nonce and 'strict-dynamic', modern browsers ignore 'unsafe-inline' while older browsers use it as a fallback. The MDN script-src reference shows how that behavior changes across CSP versions.
The strongest general-purpose pattern is to trust scripts by a fresh nonce or a stable hash, then keep the origin allowlist small. A nonce must be unpredictable and generated for each response. A hardcoded nonce copied into every page is just a strangely spelled allowlist.
3. Run a controlled blocking test
Do this in staging. Do not paste a random “XSS test” into production and call the result a vulnerability assessment.
Temporarily add two scripts to a test page:
<script>
window.cspInlineTest = "executed";
</script>
<script src="https://example.com/csp-test.js"></script>
Neither script should be authorized by the test page’s policy. Reload with DevTools open and confirm three things:
- The console records a CSP violation for each blocked script.
- The external script does not appear as a successful request in the Network panel.
window.cspInlineTestremains undefined.
Then add the correct nonce to the inline script and verify that only the nonced script runs. This proves both sides of the control: untrusted code is blocked and intended code still works.
This is an enforcement test, not proof that the page has or does not have XSS. Finding an injection path requires a separate review of how untrusted data reaches HTML, script, URLs, styles, and DOM APIs.
4. Exercise the application, not just the homepage
A strict header that breaks checkout is not ready to ship. Walk through the routes with the messiest dependencies:
- Sign-in and password recovery
- Checkout and embedded payment frames
- File uploads and previews
- Support chat or feedback widgets
- Analytics and consent changes
- Pages with Web Workers, maps, video, or generated downloads
Watch the console while using the actual controls. Third-party tools often load a second or third origin after their bootstrap script runs. Adding the first hostname from a vendor’s setup guide may not be enough.
Do not respond by adding * or an entire scheme. Add the smallest directive and origin that explains the blocked behavior, then repeat the flow.
5. Collect reports before enforcement
For an existing site, start with Content-Security-Policy-Report-Only. It gives you the browser’s view of what a stricter policy would block without breaking visitors.
A modern reporting setup can look like this:
Reporting-Endpoints: csp="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; object-src 'none'; base-uri 'none'; report-to csp
Reports are noisy. Browser extensions inject scripts, users run bookmarklets, and some reports omit useful context. Group by directive, blocked origin, route, and release. Fix repeatable violations caused by your application before worrying about one-off extension traffic.
The MDN CSP guide covers nonce, hash, and reporting patterns without pretending that one policy fits every application.
6. Check the directives people forget
script-src gets the attention, but these directives close common gaps:
object-src 'none'removes legacy plugin content.base-uri 'none'or a narrow source prevents an injected<base>element from rewriting relative URLs.frame-ancestorscontrols who may embed the page.form-actionlimits where forms may submit.connect-srclimits fetch, WebSocket, and related outbound connections.worker-srccovers Worker and Service Worker scripts.
Also set an intentional fallback with default-src. A missing specialized directive can fall back to it, sometimes in ways that are easy to miss during a quick review.
A useful starting policy
This is a starting point for a server-rendered application, not something to paste into production unchanged:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
form-action 'self';
report-to csp;
Your real policy will probably need fonts, payment frames, media, or trusted API origins. Add those because a tested feature requires them, not because an online generator produced a longer header.
My definition of done
I call a CSP ready when:
- Enforced and report-only headers are understood on every important route
- Unauthorized inline and external scripts fail in a controlled test
- Authorized nonce or hash scripts still run
- Checkout, authentication, forms, and third-party widgets work
- Reports arrive somewhere a person will review them
- New sources require a deliberate code or configuration change
After that, put the test in your pre-launch website checklist. SiteCMD’s live-site security checks can catch a missing or obviously weak header, but the browser exercise is what proves the policy fits your application.