Content Security Policy without breaking your site
Build a CSP that stops injected scripts: report-only rollout, nonces and strict-dynamic, the directives that matter, and fixing violations step by step.
What CSP does
Cross-site scripting (XSS) happens when an attacker gets their JavaScript to run in your page, for example through a comment field or a URL parameter that is echoed without escaping. The browser cannot tell that script apart from yours. Content Security Policy, specified by the W3C, lets you tell the browser which sources of script and other content are legitimate.
CSP is a second line of defence. Escaping output and sanitising input remain the primary fix for XSS; CSP limits the damage when a bug slips through. It also controls framing of your site, where forms may submit, and whether the page may load mixed content.
Directives that matter
| Directive | Controls | Typical value |
|---|---|---|
default-src | Fallback for fetch directives not listed | 'self' |
script-src | JavaScript sources | 'nonce-…' 'strict-dynamic' |
style-src | Stylesheets and inline styles | 'self' (plus nonces for inline styles) |
img-src | Images | 'self' data: |
connect-src | fetch, XHR, WebSocket targets | 'self' https://api.example.com |
font-src | Web fonts | 'self' |
frame-src | Frames your page embeds | Specific hosts or 'none' |
object-src | Plugins such as <object> and <embed> | 'none' |
base-uri | The <base> element | 'none' or 'self' |
form-action | Where forms may submit | 'self' |
frame-ancestors | Who may frame your page (clickjacking) | 'self' or 'none' |
upgrade-insecure-requests | Rewrites http:// subresources to https:// | (no value) |
frame-ancestors, sandbox and reporting directives only work in the HTTP header, not in a <meta http-equiv> tag. Use the header whenever you can. frame-ancestors also supersedes the older X-Frame-Options header in current browsers, although sending both is harmless.
Host allowlists versus nonces
Early CSP deployments listed allowed script hosts: script-src 'self' https://cdn.example.net https://analytics.example.org. Research by Google engineers showed that most such policies can be bypassed, because allowed CDNs and analytics hosts often serve scripts or JSONP endpoints an attacker can abuse. Long allowlists are also fragile: every new third-party script needs a policy change.
A nonce-based policy trusts scripts by a random value instead of by location. The server generates a new unguessable nonce for every response, adds it to the header and to each legitimate <script> tag. An injected script does not know the nonce, so it does not run.
Content-Security-Policy: script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="4AEemGb0xJptoIGFP3Nd" src="/assets/app.js"></script>
<script nonce="4AEemGb0xJptoIGFP3Nd">window.appConfig = { locale: "en" };</script>'strict-dynamic' extends that trust to scripts loaded by a trusted script, such as a tag manager inserting other tags, without listing their hosts. Browsers that support 'strict-dynamic' ignore host allowlists and 'unsafe-inline' in the same directive, which lets you add those as fallbacks for very old browsers without weakening the policy for modern ones.
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
report-to cspNonces must be unique per response
Hashes for static pages
Static sites without server-side rendering cannot add a fresh nonce to each response. Instead, list the SHA-256 hash of each inline script's exact content, such as 'sha256-…', or move inline scripts into files served from your own origin. External scripts loaded from 'self' then need no nonce at all.
Rolling out without breaking anything
- Inventory. List the scripts, styles, fonts, frames and API endpoints the site uses, including third parties such as analytics, chat widgets and payment forms.
- Report-only. Send the policy as
Content-Security-Policy-Report-Only. Browsers report violations but block nothing. - Collect reports. Configure a reporting endpoint and look at the browser console on key pages and flows.
- Fix the page, not just the policy. Move inline event handlers (
onclick=) into script files, add nonces to legitimate inline scripts, remove unused third parties. - Enforce. Switch to
Content-Security-Policyonce reports only show browser extensions and noise. Keep the report-only header with a stricter candidate policy if you want to tighten further. - Watch. Keep reporting on; new features and third-party changes will produce new violations.
Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to cspreport-to with the Reporting-Endpoints header is the current mechanism. The older report-uri directive is deprecated but still understood by browsers that do not support report-to, so many sites send both during the transition. Reports are JSON; expect noise from browser extensions that inject scripts, and filter by blocked-uri and source-file.
Common violations and fixes
| Violation | Cause | Fix |
|---|---|---|
| Inline script blocked | <script> without a nonce | Add the per-response nonce or move the code to a file |
| Inline event handler blocked | onclick="…" attributes | Attach listeners in JavaScript with addEventListener |
javascript: URL blocked | Links like href="javascript:…" | Use a button with a listener |
eval blocked | Libraries using eval or new Function | Update the library; 'unsafe-eval' is a last resort |
| Style blocked | Inline style= attributes or <style> blocks | Move to stylesheets or nonce the <style> element |
| Connection blocked | API or analytics endpoint not in connect-src | Add the exact origin |
| Frame blocked | Embedded video, map or payment frame | Add the host to frame-src |
Fix violations in order of risk: script violations first, then connections and frames, and styles last. Script sources decide whether injected code can run, while most style violations are cosmetic.
Resist adding 'unsafe-inline' to script-src without nonces or hashes. It allows exactly the injected inline scripts CSP is supposed to stop, and the HTTP Headers Checker reports it as a weak policy. 'unsafe-inline' in style-src is a smaller risk and a common compromise while inline styles are cleaned up.
A worked example: tightening a real policy
Consider a marketing site at example.com that has grown a policy over the years. It works, but the HTTP Headers Checker marks it as weak because script-src contains 'unsafe-inline' and a wildcard. Here is the starting point and the policy it can reach in a few iterations.
Content-Security-Policy: default-src 'self' https:; script-src 'self' 'unsafe-inline' https://*.example.net https://analytics.example.org; style-src 'self' 'unsafe-inline'; img-src * data:The problems are easy to name. 'unsafe-inline' lets any injected inline script run, https://*.example.net trusts every host under a shared CDN domain, default-src https: allows any HTTPS origin for everything not listed, and img-src * allows images from anywhere, which can leak data through image URLs. There is also no object-src, base-uri or frame-ancestors.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://analytics.example.org;
connect-src 'self' https://analytics.example.org;
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'self';
report-to cspGetting there took three changes to the site rather than to the policy: inline onclick handlers moved into the main script, the analytics loader got the nonce, and a legacy widget that needed eval was replaced. 'unsafe-inline' in style-src stays for now as a documented compromise. In browsers that support nonces, the https: and 'unsafe-inline' entries in script-src are ignored, so they only keep very old browsers working.
| Iteration | Change | Report-only result |
|---|---|---|
| 1 | Added object-src 'none'; base-uri 'none'; frame-ancestors 'self' | No violations |
| 2 | Replaced the host allowlist with nonce and 'strict-dynamic' | Inline handlers and one eval reported |
| 3 | Moved handlers to script, replaced the eval widget | Only browser extension noise |
| 4 | Tightened default-src, img-src and connect-src | Two analytics endpoints added, then clean |
Beyond scripts
Clickjacking
frame-ancestors 'none' prevents any site from framing yours; 'self' allows only your own pages. It replaces X-Frame-Options: DENY and SAMEORIGIN in modern browsers.
Mixed content
upgrade-insecure-requests makes the browser fetch http:// images, scripts and styles over HTTPS. It helps during migrations of old content, together with HSTS for the page itself.
Trusted Types
require-trusted-types-for 'script' makes supporting browsers reject strings passed to dangerous DOM sinks such as innerHTML unless they come from an approved policy. It targets DOM-based XSS that nonces cannot catch. It needs code changes and is best added after the main policy is stable.
Keeping the policy healthy
A CSP is not a one-time project. Every new feature, marketing tool or embedded widget can need a policy change, and a policy nobody owns tends to accumulate exceptions until it no longer protects anything. Give the policy an owner, keep it in version control next to the application, and review changes to it like code changes.
Two habits keep it tight. First, when adding a source, prefer the narrowest form: an exact origin in connect-src rather than a wildcard, and a nonce rather than a new script host. Second, review violation reports monthly: a sudden rise often means a new third-party script was added without telling anyone, or that an injection attempt is being blocked, and both are worth knowing about.
Finally, test the policy as part of deployment. A simple check that fetches key pages and fails the pipeline when the Content-Security-Policy header is missing or contains 'unsafe-inline' without a nonce catches accidental regressions, such as a proxy configuration change that drops the header.
CSP with frameworks, CDNs and tag managers
Server-rendered frameworks usually have a way to inject a nonce into every script tag they render; look for CSP or nonce support in the framework's documentation. Single-page applications built into static files work well with a policy that allows scripts from 'self' and has no inline scripts at all. Build tools can often be configured to avoid inline bootstrap scripts.
CDNs and edge platforms can add or rewrite headers, which is convenient but can also leave two different policies in effect. When two CSP headers are present, the browser enforces both, and a resource must be allowed by every policy. Check the final response with the HTTP Headers Checker after each deployment.
Tag managers are the hardest case, because marketing teams add scripts without code changes. With 'strict-dynamic', scripts inserted by a nonce-trusted tag manager are allowed, which keeps things working but also means anyone with access to the tag manager can run code on your site. Protect that access like production deploy rights.
FAQ
Can I use CSP in a meta tag?
Partly. <meta http-equiv="Content-Security-Policy"> supports most fetch directives, but not frame-ancestors, sandbox or reporting. The HTTP header is the complete option.
Does CSP replace output escaping?
No. Escaping and sanitising stop XSS at the source; CSP limits the impact when something is missed.
Why does my policy block Google Analytics or a chat widget?
Their scripts load other scripts and connect to their own endpoints. Use a nonce with 'strict-dynamic' for the loader, and add the required endpoints to connect-src and img-src as their documentation lists.
What is the difference between report-uri and report-to?
report-uri is the older, deprecated directive that takes a URL. report-to references an endpoint named in the Reporting-Endpoints header. Sending both keeps reports flowing from all browsers.
Is 'unsafe-inline' in style-src dangerous?
Much less than in script-src, but injected styles can still be used for data exfiltration tricks or UI spoofing. Treat it as a temporary compromise.
Can I use the same nonce for all scripts on a page?
Yes. One nonce per response is shared by every legitimate script tag in that response. What must not happen is the same nonce being reused across responses or users, for example through a page cache.
Does a CSP slow down the site?
No measurable amount for users. The browser checks each resource against the policy, which is cheap. The work is on the team side: keeping the policy in step with new features and third parties.
Should an API that only returns JSON send a CSP?
It does no harm and protects against a response being rendered as HTML by mistake. A minimal default-src 'none'; frame-ancestors 'none' is common for APIs.
How do I handle browser extension noise in reports?
Extensions inject scripts and styles, which show up as violations with blocked-uri values like chrome-extension or with inline sources you do not recognise. Filter them out when summarising reports; they are not problems in your site.
How long should report-only run?
Until all key pages and user flows have been exercised and reports show only noise, typically one to several weeks for an active site.