CSP for Tidio Live Chat: A Practical Setup Guide

Table of Contents

Tidio is one of those integrations that looks trivial until your CSP blocks half of it.

You paste the widget snippet, refresh, and the chat bubble never appears. Then DevTools starts yelling about script-src, connect-src, frame-src, maybe img-src, and sometimes even style-src. If your site already has a reasonably locked-down policy, Tidio will force you to be explicit.

I’ve had better results treating chat widgets like mini-applications instead of “just another script”. They load scripts, open WebSocket connections, render frames, fetch images, and inject styles. Your CSP has to allow that behavior without turning into default-src * 'unsafe-inline' 'unsafe-eval'.

Here’s how I’d set up CSP for Tidio on a real site.

Start from a real baseline

A realistic CSP often already exists before you add chat. For example, this is a real policy shape from headertest.com:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-OWU2OWI1OTctNzE5NC00ZjgwLWJkNjctMjFjOTgxYTBjNWUy' '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'

That’s a decent starting point: nonces, strict-dynamic, no object-src, locked frame-ancestors. Good.

Now add Tidio, and the missing pieces are usually:

  • script-src for Tidio’s loader
  • connect-src for API and WebSocket traffic
  • frame-src for embedded chat UI
  • img-src for avatars and assets
  • sometimes style-src if the widget injects inline styles or loads CSS
  • sometimes font-src depending on how assets are served

The Tidio embed code

A typical Tidio install looks like this:

<script src="//code.tidio.co/YOUR_PUBLIC_KEY.js" async></script>

If your CSP is strict, that script won’t load unless you explicitly allow it or load it via a nonce/hashes strategy that permits dynamic descendants.

If you already use nonces and strict-dynamic, I prefer adding the nonce to your inline bootstrap script and then injecting Tidio from there.

Example:

<script nonce="{{ .CSPNonce }}">
  const s = document.createElement('script');
  s.src = 'https://code.tidio.co/YOUR_PUBLIC_KEY.js';
  s.async = true;
  document.head.appendChild(s);
</script>

With strict-dynamic, the trusted nonce-bearing script can load Tidio without you having to keep whitelisting every descendant script host. That’s cleaner than spraying domains into script-src.

Minimal CSP changes for Tidio

If you want the shortest path to a working setup, start with something like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://code.tidio.co;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://code.tidio.co https://api-v2.tidio.co wss://ws-widget.tidiochat.com https://widget-v4.tidiochat.com;
  frame-src 'self' https://www.tidiochat.com https://widget-v4.tidiochat.com;
  font-src 'self' data: https:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

This is not guaranteed to be the exact final list for every Tidio deployment, because vendors change hostnames over time. Chat vendors do this a lot. Expect to confirm actual requests in DevTools and refine from there.

Merging Tidio into an existing policy

Using the headertest.com policy shape, here’s what a merged version might look like:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-OWU2OWI1OTctNzE5NC00ZjgwLWJkNjctMjFjOTgxYTBjNWUy' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com https://code.tidio.co;
  style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self' data: https:;
  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://code.tidio.co
    https://api-v2.tidio.co
    https://widget-v4.tidiochat.com
    wss://ws-widget.tidiochat.com;
  frame-src 'self' https://consentcdn.cookiebot.com https://www.tidiochat.com https://widget-v4.tidiochat.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

That’s the kind of policy I’d actually ship first in report-only mode, then tighten after watching violations.

Express example

If you’re setting headers in Node/Express, here’s a practical pattern with a nonce:

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.use((req, res, next) => {
  const nonce = crypto.randomUUID();

  res.locals.cspNonce = nonce;

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://code.tidio.co 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' data: https:",
    "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://code.tidio.co https://api-v2.tidio.co https://widget-v4.tidiochat.com wss://ws-widget.tidiochat.com",
    "frame-src 'self' https://consentcdn.cookiebot.com https://www.tidiochat.com https://widget-v4.tidiochat.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'"
  ].join('; ');

  res.setHeader('Content-Security-Policy', csp);
  next();
});

app.get('/', (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <head>
        <script nonce="${res.locals.cspNonce}">
          const s = document.createElement('script');
          s.src = 'https://code.tidio.co/YOUR_PUBLIC_KEY.js';
          s.async = true;
          document.head.appendChild(s);
        </script>
      </head>
      <body>
        <h1>Hello</h1>
      </body>
    </html>
  `);
});

app.listen(3000);

I like this pattern because it keeps inline bootstrapping trusted without falling back to 'unsafe-inline' in script-src.

Nginx example

If you terminate at Nginx and your app doesn’t generate a nonce, you can still allow Tidio directly in script-src:

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' https://code.tidio.co;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' data: https:;
  connect-src 'self' https://code.tidio.co https://api-v2.tidio.co https://widget-v4.tidiochat.com wss://ws-widget.tidiochat.com;
  frame-src 'self' https://www.tidiochat.com https://widget-v4.tidiochat.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
" always;

That works, but I’d still choose nonces if you already have a modern CSP. Direct host allowlists age badly.

Debugging the usual CSP failures

When Tidio breaks, the browser console usually tells you exactly which directive is missing.

Blocked script

Error shape:

Refused to load the script 'https://code.tidio.co/...' because it violates the following Content Security Policy directive: "script-src ..."

Fix:

  • add https://code.tidio.co to script-src, or
  • load it from a nonce-bearing script if you use strict-dynamic

Blocked WebSocket

Error shape:

Refused to connect to 'wss://ws-widget.tidiochat.com/...' because it violates the following Content Security Policy directive: "connect-src ..."

Fix:

  • add wss://ws-widget.tidiochat.com to connect-src

This one gets missed constantly. People remember HTTPS endpoints and forget WebSockets.

Blocked iframe

Error shape:

Refused to frame 'https://widget-v4.tidiochat.com/...' because it violates the following Content Security Policy directive: "frame-src ..."

Fix:

  • add the reported Tidio host to frame-src

Blocked image or avatar

Error shape:

Refused to load the image 'https://...' because it violates the following Content Security Policy directive: "img-src ..."

Fix:

  • if you already allow https: in img-src, you may be fine
  • otherwise add the specific image host

I usually keep img-src 'self' data: https: unless I have a strong reason to constrain it further.

Use Report-Only first

For third-party widgets, I strongly recommend starting with:

Content-Security-Policy-Report-Only: ...

That lets you collect violations without breaking chat for users. Once requests stabilize, move the policy to enforcing mode.

If you want ready-made policy shapes for comparison, https://csp-examples.com is handy.

A stricter version

If you want to tighten the policy after confirming Tidio behavior, here’s the direction I’d take:

  • keep object-src 'none'
  • keep base-uri 'self'
  • keep frame-ancestors 'none'
  • avoid 'unsafe-inline' in script-src
  • only keep 'unsafe-inline' in style-src if the widget actually needs it
  • replace broad https: host allowances with exact domains where feasible

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://code.tidio.co;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://widget-v4.tidiochat.com https://code.tidio.co;
  font-src 'self' data:;
  connect-src 'self' https://api-v2.tidio.co https://widget-v4.tidiochat.com wss://ws-widget.tidiochat.com;
  frame-src 'self' https://www.tidiochat.com https://widget-v4.tidiochat.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

That’s closer to what I’d call maintainable.

Final checklist

Before you call it done, verify all of these in DevTools:

  • Tidio loader script fetches successfully
  • chat bubble appears
  • widget opens
  • WebSocket connection succeeds
  • avatars and icons load
  • no CSP violations on page load or interaction
  • policy still blocks unexpected inline script execution

Official CSP documentation is here: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

And the practical rule with Tidio is simple: don’t guess the final host list. Start with a tight baseline, run in report-only mode, click through the chat flow, and allow only what the widget actually uses. That gets you a working live chat without gutting your CSP.