I’ve seen this exact problem a bunch of times: a team has a pretty decent Content Security Policy, marketing wants Intercom, someone pastes the vendor snippet into the site, and suddenly the browser console looks like a crime scene.
The hard part isn’t getting Intercom to load. The hard part is getting it to load without wrecking a policy that was actually doing useful work.
Here’s a realistic case study using a real baseline CSP from headertest.com, then tightening it up for Intercom live chat with minimal blast radius.
The starting point
This is the real CSP header you gave me, slightly reformatted for sanity:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-YTgzMWE0YWEtMDg1NC00YjIyLTg3ZDEtZTgwYWRmOGEzM2Y4' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
Honestly, this is not bad. A few things stand out:
script-srcuses a nonce and'strict-dynamic', which is good.object-src 'none',base-uri 'self',frame-ancestors 'none'are exactly what I like to see.img-src 'self' data: https:is broad, but common.style-src 'unsafe-inline'is weaker than I’d want, but lots of third-party widgets force that tradeoff.font-src 'self'is going to be a problem the second a third-party widget wants hosted fonts.frame-srcandconnect-srcare restrictive enough that Intercom will definitely hit them.
The “before” situation
The team drops in the standard Intercom boot snippet. Usually it looks roughly like this:
<script nonce="{{ .CSPNonce }}">
window.intercomSettings = {
app_id: "abc123xy"
};
</script>
<script nonce="{{ .CSPNonce }}">
(function(){
var w=window;var ic=w.Intercom;
if(typeof ic==="function"){
ic('reattach_activator');ic('update',w.intercomSettings);
} else {
var d=document;var i=function(){i.c(arguments)};i.q=[];i.c=function(args){i.q.push(args)};
w.Intercom=i;
var l=function(){
var s=d.createElement('script');
s.type='text/javascript';s.async=true;
s.src='https://widget.intercom.io/widget/abc123xy';
var x=d.getElementsByTagName('script')[0];
x.parentNode.insertBefore(s,x);
};
if(document.readyState==='complete'){l();}
else if(w.attachEvent){w.attachEvent('onload',l);}
else{w.addEventListener('load',l,false);}
}
})();
</script>
Because the inline scripts are nonce’d, the bootstrap code itself is usually fine.
Then the browser starts complaining:
Refused to connect to 'https://api-iam.intercom.io/...'
because it violates the following Content Security Policy directive:
"connect-src 'self' https://api.headertest.com ..."
Refused to frame 'https://js.intercomcdn.com/...'
because it violates the following Content Security Policy directive:
"frame-src 'self' https://consentcdn.cookiebot.com"
Refused to load the script 'https://widget.intercom.io/widget/abc123xy'
because it violates the following Content Security Policy directive:
"script-src 'self' 'nonce-...' 'strict-dynamic' ..."
That last error deserves a quick note.
With 'strict-dynamic', host allowlists in script-src are ignored by supporting browsers once a nonce or hash is present. That’s not a bug; that’s the feature. If your nonce’d bootstrap script dynamically injects the Intercom script, modern browsers should allow it because trust flows from the nonce’d script.
But older browsers don’t fully behave that way, and mixed browser support is where people get surprised. If you care about graceful fallback, you may still choose to list Intercom script origins explicitly.
What Intercom usually needs
For the Messenger widget, you should expect to allow some mix of these origin families:
https://widget.intercom.iofor the loader scripthttps://js.intercomcdn.comfor widget assetshttps://*.intercom.iofor API and messaging endpointshttps://*.intercomcdn.comfor static assetswss://*.intercom.iofor websocket connectionshttps://intercom-sheets.comin some setupshttps://uploads.intercomcdn.comor similar for attachments/images
Check the current official docs before locking anything in:
https://www.intercom.com/help/en/articles/3894-install-intercom-on-your-web-product-or-site
and your CSP behavior against the CSP spec/docs:
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
Vendor infrastructure changes. That’s reality. I don’t love broad wildcards, but for chat widgets, trying to pin every single subdomain can become a maintenance tax.
The naive fix
The first fix people ship is usually this kind of mess:
Content-Security-Policy:
default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval';
style-src * 'unsafe-inline';
img-src * data: blob:;
connect-src * wss:;
frame-src *;
Yes, Intercom works. So would half the malware on the internet.
This is the classic third-party-widget panic response: “CSP is blocking the business, just make it permissive.” I’ve had to unwind these after audits, and it’s never fun.
The better fix
Start from the existing header and add only what Intercom actually needs.
Here’s a practical “after” policy based on your real baseline:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com https://widget.intercom.io https://js.intercomcdn.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self' https://js.intercomcdn.com;
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com;
frame-src 'self' https://consentcdn.cookiebot.com https://js.intercomcdn.com https://widget.intercom.io https://intercom-sheets.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-to default-endpoint;
report-uri /csp-report
That’s a reasonable production starting point.
Why each change exists
script-src
https://widget.intercom.io https://js.intercomcdn.com
If you’re already using a nonce plus 'strict-dynamic', modern browsers may not need these hosts for the injected loader path. I still include them when rolling out vendor widgets because:
- they help with compatibility in older browsers
- they make intent obvious during reviews
- they reduce “why is this blocked in browser X?” debugging
connect-src
https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com
This is the big one. Live chat widgets talk constantly:
- boot/auth calls
- message sync
- presence/state polling or sockets
- attachment/image fetches
If you forget wss://, the launcher might render but conversation updates will break in weird ways.
frame-src
https://js.intercomcdn.com https://widget.intercom.io https://intercom-sheets.com
Intercom uses iframes for parts of the Messenger UI. If your policy only allows Cookiebot frames, Intercom will fail even though the bootstrap script loaded fine.
font-src
https://js.intercomcdn.com
This one gets missed a lot. The widget can render, but typography/icons break. Then someone files a “UI looks weird in production” ticket and nobody suspects CSP for two days.
Before and after: the practical diff
Here’s the shortest useful diff from your original policy:
script-src:
+ https://widget.intercom.io
+ https://js.intercomcdn.com
font-src:
+ https://js.intercomcdn.com
connect-src:
+ https://*.intercom.io
+ wss://*.intercom.io
+ https://*.intercomcdn.com
frame-src:
+ https://js.intercomcdn.com
+ https://widget.intercom.io
+ https://intercom-sheets.com
That’s the shape I’d start with.
Safer rollout with Report-Only
Don’t push this straight to enforcement if you can avoid it. Ship a Content-Security-Policy-Report-Only header first and watch what the widget actually tries to do in your environment.
Example:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://widget.intercom.io https://js.intercomcdn.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://js.intercomcdn.com;
connect-src 'self' https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com;
frame-src 'self' https://js.intercomcdn.com https://widget.intercom.io https://intercom-sheets.com;
object-src 'none';
base-uri 'self';
form-action 'self';
report-uri /csp-report
If you want ready-made policy patterns, https://csp-examples.com is handy for comparing structures quickly.
A couple of hard-earned lessons
1. Don’t dump Intercom domains into default-src
That’s lazy policy design. Keep permissions in the directives where they belong. If Intercom needs connect-src, say that. Don’t quietly widen everything.
2. Keep the nonce-based bootstrap
Your current policy already uses a nonce and 'strict-dynamic'. That’s better than switching back to 'unsafe-inline' because a vendor gave you a copy-paste snippet.
3. Expect asset drift
Third-party chat vendors change infrastructure. Add reporting, review violations, and treat CSP maintenance as part of owning the integration.
4. Test the full chat flow
Don’t stop at “launcher bubble appears.” Test:
- opening the Messenger
- loading past messages
- sending a message
- uploading an attachment
- mobile viewport behavior
- logged-in vs logged-out states
CSP failures often hide in the second or third interaction.
Final production example
If I were shipping this on a site with your baseline, I’d start here:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com https://widget.intercom.io https://js.intercomcdn.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self' https://js.intercomcdn.com;
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com;
frame-src 'self' https://consentcdn.cookiebot.com https://js.intercomcdn.com https://widget.intercom.io https://intercom-sheets.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-uri /csp-report
Not perfect. No real production CSP ever is. But it keeps the original structure intact, preserves the strong parts of the policy, and makes Intercom work without turning CSP into decorative security.