If you embed the Chatwoot widget, you’re adding a live JavaScript application to your site. That’s fine. I like Chatwoot. But I’ve seen teams treat the widget snippet like a harmless copy-paste include, then forget it touches the DOM, handles user-controlled content, and often runs with broad trust in production.

That’s where XSS mistakes show up.

The Chatwoot widget itself isn’t the only thing to think about. The bigger problem is usually how developers pass data into it, how they customize the launcher, and how they weaken their own defenses to “make the widget work.”

Here are the most common mistakes I see, and the fixes that actually hold up.

Mistake #1: Passing unsanitized user data into widget config

A classic pattern looks like this:

<script>
  window.chatwootSettings = {
    position: "right",
    type: "standard",
    launcherTitle: "{{ user.display_name }}"
  };
</script>

If user.display_name contains quotes, HTML, or script-breaking payloads, you’ve created an injection point before the widget even loads.

A lot of teams assume “it’s just a string in a script block.” That’s exactly why it’s dangerous.

Bad example

<script>
  window.chatwootSettings = {
    launcherTitle: "{{ request.query.name }}"
  };
</script>

If name is:

";alert(1);//

you’ve turned your config object into executable JavaScript.

Fix

Serialize data as JSON on the server, not by string concatenation.

<script>
  window.chatwootSettings = JSON.parse('{{ chatwoot_settings_json | escapejs }}');
</script>

Or better, inject a non-executable JSON blob:

<script type="application/json" id="chatwoot-settings">
  {{ chatwoot_settings_json | safe }}
</script>

<script>
  const settingsEl = document.getElementById('chatwoot-settings');
  window.chatwootSettings = JSON.parse(settingsEl.textContent);
</script>

If your framework supports safe JSON helpers, use them:

Rails

<script type="application/json" id="chatwoot-settings">
  <%= raw({
    position: "right",
    type: "standard",
    launcherTitle: current_user.display_name
  }.to_json) %>
</script>

Django

{{ chatwoot_settings|json_script:"chatwoot-settings" }}

<script>
  window.chatwootSettings = JSON.parse(
    document.getElementById("chatwoot-settings").textContent
  );
</script>

The rule is simple: never build JavaScript objects with raw template interpolation if any field can be influenced by users.

A lot of Chatwoot integrations add custom UI around the widget: unread count badges, “chat with us” banners, support previews, agent names, or welcome text pulled from an API.

Then somebody writes this:

banner.innerHTML = `
  <strong>${agent.name}</strong>
  <p>${agent.welcome_message}</p>
`;

If either field is attacker-controlled, or even admin-controlled in a multi-user system, that’s an XSS bug.

People love to say, “Only our support team can edit that text.” Sure. Until one support account gets compromised, or someone pastes rich HTML from somewhere sketchy.

Fix

Use textContent for plain text.

const strong = document.createElement('strong');
strong.textContent = agent.name;

const p = document.createElement('p');
p.textContent = agent.welcome_message;

banner.replaceChildren(strong, p);

If you really need formatted HTML, sanitize it with a proven sanitizer like DOMPurify.

import DOMPurify from 'dompurify';

banner.innerHTML = DOMPurify.sanitize(agent.welcome_message_html, {
  ALLOWED_TAGS: ['b', 'i', 'strong', 'em', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'target', 'rel']
});

I’m opinionated about this one: if you don’t absolutely need HTML, don’t allow HTML. Most “rich text welcome messages” are not worth the risk.

Mistake #3: Trusting custom attributes and URL params too much

I’ve seen developers wire Chatwoot behavior to query params:

const params = new URLSearchParams(location.search);
window.chatwootSettings = {
  launcherTitle: params.get('title'),
  locale: params.get('lang')
};

Or to data-* attributes rendered from CMS content:

<div
  id="chat-config"
  data-launcher-title="{{ page.chat_title }}"
  data-welcome="{{ page.welcome_message }}">
</div>

Then they read those values and push them into the DOM elsewhere.

The problem isn’t just the widget config. The problem is that these values often get reused later in unsafe sinks.

Fix

Validate every field by type and expected format.

const params = new URLSearchParams(location.search);

const allowedLocales = new Set(['en', 'fr', 'de', 'es']);
const locale = allowedLocales.has(params.get('lang')) ? params.get('lang') : 'en';

const launcherTitleRaw = params.get('title') || 'Chat with us';
const launcherTitle = launcherTitleRaw.slice(0, 60);

window.chatwootSettings = {
  locale,
  launcherTitle
};

Validation won’t replace output encoding, but it shrinks your attack surface a lot.

Mistake #4: Weak CSP because “the widget needs inline scripts”

This one annoys me because it’s so common. A team adds Chatwoot, the CSP breaks, and the quick fix is:

Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval' https://app.chatwoot.com

That’s not a fix. That’s surrender.

If you allow unsafe-inline, reflected and stored XSS get much easier to exploit. If you add unsafe-eval too, you’ve made life even easier for attackers and harder for yourself.

Fix

Use nonces or hashes for your own inline bootstrap script, and keep the policy tight.

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m123' https://app.chatwoot.com;
  connect-src 'self' https://app.chatwoot.com wss://app.chatwoot.com;
  img-src 'self' data: https:;
  style-src 'self' 'unsafe-inline';
  frame-src https://app.chatwoot.com;

And your script tag:

<script nonce="r4nd0m123">
  (function(d,t) {
    var BASE_URL="https://app.chatwoot.com";
    var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
    g.src=BASE_URL+"/packs/js/sdk.js";
    g.async = true;
    s.parentNode.insertBefore(g,s);
    g.onload=function() {
      window.chatwootSDK.run({
        websiteToken: "YOUR_TOKEN",
        baseUrl: BASE_URL
      });
    }
  })(document,"script");
</script>

If you need help designing a CSP without punching giant holes in it, csp-guide.com is useful.

Also, test your headers instead of guessing. I usually run sites through HeaderTest to spot broken CSP, missing framing protections, and other security header mistakes fast.

Mistake #5: Forgetting that stored XSS can come from support-side content

A Chatwoot widget is a chat surface. Chat systems deal with messages, contact names, metadata, canned responses, conversation labels, and integrations. If any of that gets reflected into your site or your custom front-end code, stored XSS becomes very real.

Example: you fetch recent conversation info and render it in a custom widget shell.

fetch('/api/chat-preview')
  .then(r => r.json())
  .then(data => {
    preview.innerHTML = data.lastMessage;
  });

That lastMessage may have originated from a visitor, an agent, or a third-party integration.

Fix

Treat chat-originated content as untrusted, always.

fetch('/api/chat-preview')
  .then(r => r.json())
  .then(data => {
    preview.textContent = data.lastMessage;
  });

If you need markdown or rich formatting, sanitize after rendering, not before trust magically appears.

Mistake #6: Building your own “safe” sanitizer

I still see code like this in production:

function sanitize(input) {
  return input
    .replace(/<script.*?>.*?<\/script>/gi, '')
    .replace(/on\w+=".*?"/gi, '');
}

This is how you end up with a false sense of security and a bug bounty report.

HTML parsing is messy. Event handlers, SVG, malformed tags, encoded payloads, nested contexts, weird browser behavior — regex hacks do not cover it.

Fix

Use a maintained sanitizer.

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirtyHtml);
container.innerHTML = clean;

And keep the allowed tag list small. Every extra tag or attribute is another thing you now need to reason about.

Mistake #7: No isolation for risky customizations

Some teams go heavy on Chatwoot custom integrations: custom launchers, transcript previews, agent cards, even embedding chat-adjacent content from other systems.

If that code is doing risky rendering, don’t let it share the same trust boundary as the rest of your app without thinking.

Fix

Where possible:

  • isolate risky UI in a sandboxed iframe
  • keep widget customization code small
  • avoid mixing marketing CMS content with security-sensitive script generation
  • separate trusted config from user-generated content

This is less glamorous than a sanitizer library, but it prevents entire classes of mistakes.

A safer baseline

If you want a decent starting point, keep it boring:

<script type="application/json" id="chatwoot-settings">
{
  "position": "right",
  "type": "standard",
  "launcherTitle": "Chat with us"
}
</script>

<script nonce="{{ csp_nonce }}">
  const settings = JSON.parse(
    document.getElementById('chatwoot-settings').textContent
  );

  (function(d,t) {
    const BASE_URL = "https://app.chatwoot.com";
    const g = d.createElement(t);
    const s = d.getElementsByTagName(t)[0];

    g.src = BASE_URL + "/packs/js/sdk.js";
    g.async = true;
    s.parentNode.insertBefore(g, s);

    g.onload = function() {
      window.chatwootSDK.run({
        websiteToken: "YOUR_TOKEN",
        baseUrl: BASE_URL,
        ...settings
      });
    };
  })(document, "script");
</script>

And in your own surrounding UI code:

statusLabel.textContent = supportStatus.message;
agentName.textContent = agent.name;

No innerHTML, no raw interpolation into script blocks, no CSP self-sabotage.

That’s the pattern I trust.

The real lesson with Chatwoot widget XSS isn’t “widgets are dangerous.” It’s that developers tend to drop their standards when third-party scripts are involved. They loosen CSP, stop validating inputs, and start treating DOM insertion like a harmless convenience.

That’s when XSS gets in.

Keep the integration boring, strict, and text-only by default. Boring code is usually the code that survives security review.