Why your embedded script silently does nothing under a Content Security Policy
The short answer
A blocked script never runs, so it cannot report that it was blocked, and the page looks normal. Allow the vendor's host in both script-src and connect-src: miss the second and the widget appears but every request fails. If your policy uses strict-dynamic, host allowlists in script-src are ignored entirely and the tag needs your per-request nonce instead. And check production specifically, because many apps only send a CSP there.
You add a third-party widget to your app. It works in development. You deploy, and it is simply not there: no button, no error on the page, nothing in your error tracker. When an embed shows nothing at all like this, the cause is almost always a Content Security Policy.
Why the page gives no sign of it
A Content Security Policy tells the browser which sources it may load and connect to. A script from a source not on the list is never executed. That is the whole point, and it has an awkward consequence: the code that would have reported a problem is the code that was blocked. The page renders exactly as it would have without the script.
The one trace is in the browser console, a violation naming the blocked host, and in a report endpoint if your policy defines one. If you are debugging a widget that never appears, open the console on the deployed site before you look at anything else.
It needs two directives, not one
Most people add the vendor’s host to script-src and stop. That lets the script load. It does not let the script talk to its own servers, which is governed by connect-src.
Missing the second one is the sneakier failure, because it looks like progress. The widget appears, the launcher opens, and every request it makes fails. It reads like the vendor is down rather than like your policy is blocking it.
If you use strict-dynamic, the allowlist does nothing
Modern policies, and the recommended setup in frameworks like Next.js, often use 'strict-dynamic' with a per-request nonce. A browser that honours 'strict-dynamic' ignores host allowlists in script-src entirely. It trusts scripts that carry the nonce, and scripts those scripts load.
So adding the vendor’s host to script-src changes nothing, which is baffling if you do not know the rule. What the tag needs is the same nonce your own scripts get:
<script src="https://vendor.example/widget.js"
nonce={nonce} defer></script>Read the nonce wherever your framework generated it for this request and pass it to the tag. connect-src is unaffected by 'strict-dynamic', so the vendor’s host still needs to be there. That is now the half that breaks.
Why it worked on your machine
Many apps only send a Content Security Policy in production, because a strict policy fights with development tooling like hot reload. So locally there is no policy and nothing is blocked, and the first time the widget meets one is after it ships.
That makes this the most expensive shape a small bug can take. The install works all afternoon, passes review, deploys, and is silently dead. Test a new embed against a production build with the production policy before merging it, or at least check the deployed site’s console straight after release.
A short checklist
- Vendor host in
script-src, unless you use'strict-dynamic'. - With
'strict-dynamic', the per-request nonce on the tag instead. - Vendor host in
connect-src, in every case. - The tag in your root layout, so it loads on every page rather than one.
- Tested against the production policy, not the development one.
Common questions
Why is there no error when the script is blocked?
Because the code that would report it is the code that was blocked. The only trace is a CSP violation in the browser console, and a report-uri endpoint if you configured one. The page itself renders exactly as it would without the script.
I added the host to script-src and it still does not load. Why?
Your policy almost certainly uses 'strict-dynamic'. A browser that supports it ignores host allowlists in script-src and trusts only scripts carrying the nonce or hash, plus whatever those scripts load. Give the tag the same nonce your other scripts get.
Why does it work on my machine?
Many frameworks and templates only emit a Content Security Policy in production builds. Locally there is no policy, so nothing is blocked, and the first time the script meets one is after it deploys.
Keep reading
- Choosing a model for an in-app AI agent, and routing the turns that don't need it
Why prompt engineering cannot fix a routing problem, the asymmetric classifier, and the settings that silently stop applying after a model swap.
- How to test an AI agent that takes actions, without touching real data
Run it for real against development data, the five cases worth deliberately breaking, and why a timer on real execution backfires.
- The Next.js build, not the app, is what runs the server out of memory
Why a build takes down a small server when the app it produces runs fine, and the one-line cgroup fix.
Verb is this, built. An AI assistant you embed in your SaaS with one script tag: it calls your own API as the signed-in user, confirms before it changes anything, and logs every action. Free to build and test.