This post was created with AI assistance and reviewed for accuracy before publishing.
Adding a vendor's AI chat widget is a two-line change to your HTML and a much larger change to your security posture. The script runs with the same privileges as your own code: it can read the DOM, read localStorage, and make requests as the logged-in user.
Content Security Policy is how you bound that. The catch is that most widget install guides ask you to weaken exactly the directives that were doing the work.
The install instructions are the problem
Vendor docs commonly suggest one of these:
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://widget.vendor.com;
Each addition removes a specific protection. 'unsafe-inline' allows any injected <script> tag to execute, which is the primary thing CSP exists to prevent. 'unsafe-eval' allows strings to be compiled into code, re-opening a class of injection that is otherwise dead. A wildcard host allows anything the vendor ever hosts, including a compromised subdomain.
You can usually avoid all three. Treat the vendor's snippet as a starting point to be narrowed, not a requirement.
Nonces instead of unsafe-inline
If the widget needs an inline bootstrap, give that specific script a nonce rather than allowing every inline script on the page:
// middleware.ts
const nonce = crypto.randomUUID();
const csp = [
`script-src 'self' 'nonce-${nonce}' https://widget.vendor.com`,
`connect-src 'self' https://api.vendor.com`,
`frame-src https://widget.vendor.com`,
`img-src 'self' data: https://cdn.vendor.com`,
`style-src 'self' 'nonce-${nonce}'`,
`object-src 'none'`,
`base-uri 'self'`,
].join('; ');
A nonce is generated per response and must therefore come from a dynamically rendered layer. That is a real constraint: a fully static page cannot carry a per-request nonce, so on prerendered routes you are choosing between hashes and moving the widget behind an iframe.
Hashes work when the inline content is fixed. Compute the SHA-256 of the script body and allowlist that digest. It breaks the moment the vendor changes a byte, which is either a maintenance burden or an early warning, depending on your temperament.
Know which directive each behaviour needs
Widgets fail in confusing ways when one directive is missing, because the browser blocks silently from the page's perspective:
| Directive | Governs | Missing it looks like |
|---|---|---|
script-src |
Loading the widget bundle | Nothing renders at all |
connect-src |
Its API calls and WebSockets | Widget appears, never responds |
frame-src |
Widgets that render in an iframe | Empty box where the panel should be |
img-src |
Avatars, logos, uploaded images | Broken images inside the widget |
style-src |
Injected stylesheets | Unstyled widget, correct behaviour |
The second row causes the most support tickets. The panel opens, the user types, and nothing comes back, because connect-src is blocking the API host while script-src allows the bundle. The two are frequently different domains.
Roll it out in report-only first
Never enforce a new policy blind. Content-Security-Policy-Report-Only applies the rules, reports what would have been blocked, and blocks nothing:
Content-Security-Policy-Report-Only: script-src 'self' https://widget.vendor.com; report-uri /api/csp-report
Run it for long enough to cover real traffic, including the paths where the widget behaves differently. Then read the reports, add the origins you genuinely need, and only afterwards switch the header name to enforce.
Expect noise. Browser extensions inject scripts into your pages and generate violations you cannot fix and should not act on. Filter reports by whether the blocked URI is something you recognise before treating any of it as a signal.
A widget you cannot trust belongs in an iframe
CSP limits where code comes from. It does not limit what that code does once it is running in your page.
If the widget handles anything sensitive, or if you simply do not want a third party reading your DOM, put it in an iframe on a different origin. The browser's same-origin policy then does the isolation that CSP cannot: the widget cannot read your cookies, your localStorage, or the contents of the page around it. Communication goes through postMessage, where you control exactly what crosses the boundary.
window.addEventListener('message', (event) => {
if (event.origin !== 'https://widget.vendor.com') return; // check first
handle(event.data);
});
That origin check is not optional. Without it any page that can reach your frame can send it messages, and the handler will process them.
Test it on the built site, not in development
This is the part most teams get wrong. Development servers routinely relax CSP because bundler tooling needs 'unsafe-eval' for hot reloading. Which means development cannot tell you whether production will work.
Verify against a production build, and listen for violations directly rather than reading the console by eye:
document.addEventListener('securitypolicyviolation', (e) =>
console.log(e.violatedDirective, e.blockedURI)
);
That event fires even when the failure is swallowed by a try/catch inside the widget, which is common. A vendor bundle that catches its own network errors and renders an empty state gives you no console error at all, and that event is the only thing that tells you the policy was the cause.
The shape of a good outcome
You should end with a policy that names every vendor origin explicitly, has no wildcards, no 'unsafe-eval', and no blanket 'unsafe-inline'. It should be verified against a production build with a violation listener attached, and anything you genuinely cannot constrain should be sitting in a cross-origin iframe where it can only do damage to itself.
Top comments (0)