Third-party chat widgets are convenient, but they also widen your XSS attack surface. Tidio is no exception.

The widget itself is usually loaded from Tidio’s infrastructure, but the real risk tends to come from how developers pass data into it: visitor names, emails, custom variables, query parameters, CRM fields, and anything else that can end up rendered in the widget UI, agent panel, or related pages.

If you treat Tidio integration as “just paste the script and move on,” you’re asking for trouble.

Where XSS shows up with Tidio

The common pattern looks like this:

  1. Your app reads user-controlled input.
  2. You pass that input into Tidio config or custom events.
  3. The value is later rendered somewhere in HTML or a message context.
  4. Someone sneaks markup or script into the value.

Typical sources:

  • Contact form fields
  • Signup names
  • Profile fields
  • URL parameters like ?name=<payload>
  • CRM attributes synced from other systems
  • Support ticket subjects
  • Data echoed by your own frontend before sending to Tidio

Typical dangerous sinks:

  • Building the Tidio bootstrap script with string concatenation
  • Writing values into innerHTML before handing them to the widget
  • Embedding user input inside inline JavaScript
  • Rendering chat-related metadata in your own admin or support UI

The core point: Tidio may be the visible component, but the XSS usually starts in your code.

The easiest way to create an XSS bug

Here’s a bad pattern I still see all the time:

<script>
  const params = new URLSearchParams(location.search);
  const name = params.get('name') || 'Guest';

  window.tidioChatApi = window.tidioChatApi || {};
  window.tidioChatApi.visitor = {
    name: "` + name + `"
  };
</script>

If that value is inserted through server templating or sloppy string concatenation, you can break out of the JavaScript string context.

A worse version:

<script>
  const name = new URLSearchParams(location.search).get('name') || 'Guest';
  document.getElementById('welcome').innerHTML = `Hello ${name}`;
</script>

Payload:

?name=<img src=x onerror=alert(1)>

That may not execute inside the Tidio iframe, but it still executes on your page, and that’s enough to compromise the session.

Safe pattern: keep user data out of HTML and inline JS

Use DOM APIs and JSON serialization. Don’t build executable code from strings.

Safer frontend setup

<div id="welcome"></div>

<script>
  const params = new URLSearchParams(location.search);
  const rawName = params.get('name') || 'Guest';

  document.getElementById('welcome').textContent = `Hello ${rawName}`;

  window.tidioChatApi = window.tidioChatApi || {};
  window.tidioChatApi.visitor = {
    name: rawName
  };
</script>

Why this is better:

  • textContent does not interpret HTML
  • assigning object properties avoids script-string injection
  • the browser handles values as data, not code

If you render Tidio config server-side, escape for the right context

This is where teams usually get burned. They escape for HTML, then inject into JavaScript. Wrong context, wrong protection.

Bad server-side template

<script>
  window.tidioChatApi = {
    visitor: {
      name: "{{ user.name }}"
    }
  };
</script>

If user.name contains quotes, backslashes, or a closing script sequence, you can get script injection.

Good server-side template with JSON encoding

Node / Express with EJS

app.get('/support', (req, res) => {
  const visitor = {
    name: req.user?.name || 'Guest',
    email: req.user?.email || ''
  };

  res.render('support', { visitorJson: JSON.stringify(visitor) });
});
<script>
  window.tidioChatApi = {
    visitor: <%- visitorJson %>
  };
</script>

Use raw output only for trusted JSON.stringify output, not arbitrary strings.

PHP

<?php
$visitor = [
  'name' => $user['name'] ?? 'Guest',
  'email' => $user['email'] ?? ''
];
?>
<script>
  window.tidioChatApi = {
    visitor: <?= json_encode($visitor, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
  };
</script>

Python / Jinja2

import json

visitor = {
    "name": user.get("name", "Guest"),
    "email": user.get("email", "")
}
return render_template("support.html", visitor_json=json.dumps(visitor))
<script>
  window.tidioChatApi = {
    visitor: {{ visitor_json|safe }}
  };
</script>

The rule I follow: if data lands inside JavaScript, serialize it as JSON. Don’t hand-roll escaping.

Most Tidio integrations should treat user values as plain text. If you have some weird business requirement to support formatting, sanitize aggressively first.

On the frontend, DOMPurify is the standard choice:

npm install dompurify
import DOMPurify from 'dompurify';

const unsafe = userSuppliedHtml;
const safe = DOMPurify.sanitize(unsafe, {
  ALLOWED_TAGS: ['b', 'i', 'strong', 'em', 'a'],
  ALLOWED_ATTR: ['href', 'title']
});

But honestly, for chat metadata like names, order IDs, or ticket subjects, sanitization is not the first choice. Plain text is better.

Don’t trust CRM and backend fields either

A lot of XSS bugs in chat systems come from “trusted internal data” that originally came from users.

Example:

window.tidioChatApi = {
  visitor: {
    name: customer.profile.displayName,
    email: customer.profile.email,
    city: customer.crm.city
  }
};

That displayName may have been set months ago through a mobile app, legacy admin panel, import job, or Zapier sync.

Treat all externally influenced fields as untrusted, even if they come from your database.

Use CSP to reduce blast radius

CSP won’t fix bad escaping, but it makes many XSS payloads harder to exploit.

A practical baseline:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://code.tidio.co 'nonce-rAnd0m123';
  connect-src 'self' https://*.tidio.co https://code.tidio.co https://socket.tidio.co;
  img-src 'self' data: https:;
  style-src 'self' 'unsafe-inline' https:;
  frame-src https://*.tidio.co;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';
  report-to default-endpoint;

If you’re implementing CSP properly, use nonces instead of broad inline allowances. For the details, https://csp-guide.com is a solid reference.

A nonce-based script block:

<script nonce="{{cspNonce}}">
  window.tidioChatApi = {
    visitor: {
      name: "Guest"
    }
  };
</script>
<script nonce="{{cspNonce}}" src="https://code.tidio.co/your-public-key.js"></script>

Be prepared to tune connect-src and frame-src depending on exactly what Tidio endpoints your setup uses.

Audit your headers too

If you’re adding Tidio and tightening XSS defenses, check the rest of your security headers while you’re there. I usually run a quick pass with HeaderTest to catch missing CSP, bad framing rules, or sloppy legacy header configs.

Safe integration patterns

These are the patterns I’d actually recommend on production sites.

1. Pass only plain text fields

function normalizeVisitor(visitor) {
  return {
    name: String(visitor.name || '').slice(0, 100),
    email: String(visitor.email || '').slice(0, 254),
    plan: String(visitor.plan || '').slice(0, 50)
  };
}

const visitor = normalizeVisitor({
  name: currentUser.name,
  email: currentUser.email,
  plan: currentUser.plan
});

window.tidioChatApi = { visitor };

This is not full security by itself, but it reduces weird edge cases and keeps data predictable.

2. Strip markup server-side

Node example

function stripTags(input = '') {
  return String(input).replace(/<[^>]*>/g, '');
}

const visitor = {
  name: stripTags(req.body.name).slice(0, 100),
  email: stripTags(req.body.email).slice(0, 254)
};

Regex-based stripping is not a full HTML sanitizer for rich content, but for plain-text-only fields, it’s often enough as a hardening step.

3. Render user-controlled values with textContent

const el = document.getElementById('chat-user-label');
el.textContent = currentUser.name;

Not:

el.innerHTML = currentUser.name;

4. Avoid inline event handlers completely

Not this:

<button onclick="openChat('{{ user.name }}')">Chat</button>

Do this:

<button id="open-chat">Chat</button>
<script>
  document.getElementById('open-chat').addEventListener('click', () => {
    openChat(currentUser.name);
  });
</script>

Inline handlers are a CSP headache and an XSS footgun.

A quick testing checklist

When reviewing a Tidio integration, I test these cases:

  • "><script>alert(1)</script>
  • <img src=x onerror=alert(1)>
  • </script><script>alert(1)</script>
  • `;alert(1)//
  • long Unicode input
  • stored payloads from old CRM records
  • reflected payloads via URL params copied into visitor metadata

Also test both places:

  1. your public page where the widget loads
  2. any internal dashboard, transcript viewer, or support tooling that renders chat-related data

That second one is where nastier stored XSS often hides.

What to fix first

If you only have time for a short cleanup, do these in order:

  1. remove innerHTML for chat-related user data
  2. stop injecting raw template variables into inline scripts
  3. JSON-encode all Tidio config data
  4. add a real CSP with nonces
  5. review stored fields synced from forms and CRMs

Most Tidio XSS bugs aren’t “Tidio bugs.” They’re integration bugs. The widget just gives attacker-controlled data one more path to become executable. Treat every value as untrusted, encode for the right context, and keep rich HTML out of the flow unless you absolutely need it.