If you embed a chat widget, you’re adding a third-party UI surface that touches user-controlled data: names, emails, pre-chat forms, custom variables, URLs, campaign tags, support messages, and sometimes your own CRM content. That makes LiveChat-style widgets a classic XSS boundary.

The main rule is simple: treat everything that enters or leaves the widget as untrusted.

This guide focuses on the places developers usually get burned when integrating a LiveChat widget and shows safer patterns you can paste into real code.

Where XSS happens in chat widget integrations

I usually see XSS bugs around these flows:

  • Prefilling visitor data from URL params
  • Passing custom variables into the widget
  • Rendering chat transcripts or events in your own app
  • Displaying agent/customer names in admin tools
  • Showing message previews in dashboards
  • Using innerHTML to build UI around widget state
  • Trusting webhook payloads because “they came from LiveChat”

The widget itself may safely render its own UI, but your integration code is where the trouble starts.

Dangerous pattern: URL params into widget config

A common setup is grabbing marketing params or identity data from the URL and feeding them straight into the widget.

Bad

<script>
  const params = new URLSearchParams(location.search);

  window.__lc = window.__lc || {};
  window.__lc.visitor = {
    name: params.get("name"),
    email: params.get("email")
  };
  window.__lc.custom_variables = [
    { name: "campaign", value: params.get("campaign") },
    { name: "note", value: params.get("note") }
  ];
</script>

If that data later gets reflected into your own UI, logs, dashboards, CRM views, or exported transcripts, you’ve created a stored or reflected XSS path.

Better

Validate, normalize, and length-limit before sending anything.

<script>
  function cleanText(value, max = 100) {
    if (typeof value !== "string") return "";
    return value
      .replace(/[\u0000-\u001F\u007F]/g, "")
      .trim()
      .slice(0, max);
  }

  function cleanEmail(value) {
    const v = cleanText(value, 254);
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? v : "";
  }

  const params = new URLSearchParams(location.search);

  window.__lc = window.__lc || {};
  window.__lc.visitor = {
    name: cleanText(params.get("name"), 80),
    email: cleanEmail(params.get("email"))
  };
  window.__lc.custom_variables = [
    {
      name: "campaign",
      value: cleanText(params.get("campaign"), 50)
    },
    {
      name: "note",
      value: cleanText(params.get("note"), 200)
    }
  ];
</script>

That does not make the data “safe HTML.” It just keeps obvious junk out and reduces blast radius.

Never render widget data with innerHTML

This is the fastest route to DOM XSS.

Bad

function showVisitorSummary(visitor) {
  document.querySelector("#visitor").innerHTML = `
    <h3>${visitor.name}</h3>
    <p>${visitor.email}</p>
  `;
}

If visitor.name is <img src=x onerror=alert(1)>, you lose.

Good

Use textContent and explicit DOM creation.

function showVisitorSummary(visitor) {
  const root = document.querySelector("#visitor");
  root.replaceChildren();

  const h3 = document.createElement("h3");
  h3.textContent = visitor.name || "Anonymous";

  const p = document.createElement("p");
  p.textContent = visitor.email || "No email";

  root.append(h3, p);
}

If you really need limited rich text, sanitize on the server with a well-reviewed HTML sanitizer and keep the allowlist tiny.

Safe transcript rendering

A lot of teams pull chat data into an internal support panel. The data feels trusted because it came from your chat vendor. Don’t trust it. The original source was often a user typing into a text box.

Bad

function renderMessage(message) {
  const li = document.createElement("li");
  li.innerHTML = `<strong>${message.author}</strong>: ${message.text}`;
  return li;
}

Good

function renderMessage(message) {
  const li = document.createElement("li");

  const strong = document.createElement("strong");
  strong.textContent = message.author || "Unknown";

  const text = document.createTextNode(`: ${message.text || ""}`);

  li.append(strong, text);
  return li;
}

Better with a reusable escape helper

If you must build strings for a templating edge case, escape first.

function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function renderMessageHtml(message) {
  return `<li><strong>${escapeHtml(message.author)}</strong>: ${escapeHtml(message.text)}</li>`;
}

I still prefer DOM APIs over hand-rolled escaping when possible. Less room for mistakes.

Be careful with custom variables and metadata

Many teams pass order IDs, plan names, product titles, internal notes, and referral data into the widget. That’s fine until someone reuses those values in another admin page with unsafe rendering.

Safer pattern for metadata

function safeMeta(name, value, maxName = 30, maxValue = 200) {
  return {
    name: String(name).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, maxName),
    value: String(value).replace(/[\u0000-\u001F\u007F]/g, "").slice(0, maxValue)
  };
}

window.__lc = window.__lc || {};
window.__lc.custom_variables = [
  safeMeta("account_id", "acc_123456"),
  safeMeta("plan", "pro"),
  safeMeta("referrer_path", location.pathname)
];

A good habit: store raw text as text, not mini-HTML snippets. Never send things like:

{ name: "badge_html", value: "<span class=gold>VIP</span>" }

That kind of field eventually gets rendered somewhere unsafe.

Server-side output encoding still matters

If you receive chat webhooks or export transcripts into your own app, encode on output for the target context.

Different context, different encoding:

  • HTML body: escape <, >, &, quotes
  • HTML attribute: attribute-safe encoding
  • JavaScript string: JS string escaping
  • URL: URL encoding
  • CSS: don’t inject untrusted input into CSS

Node/Express example

function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

app.get("/admin/chat/:id", async (req, res) => {
  const chat = await loadChat(req.params.id);

  const items = chat.messages.map(msg => {
    return `<li><strong>${escapeHtml(msg.author)}</strong>: ${escapeHtml(msg.text)}</li>`;
  }).join("");

  res.send(`
    <!doctype html>
    <html>
      <body>
        <h1>Chat transcript</h1>
        <ul>${items}</ul>
      </body>
    </html>
  `);
});

If you use a templating engine with auto-escaping, keep it enabled. Don’t punch holes in it with raw output helpers unless you’ve sanitized content first.

CSP helps, but it won’t save sloppy DOM code

A solid Content Security Policy is worth having around any third-party widget. It reduces damage if something slips through and forces you away from inline-script chaos.

A basic example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.livechatinc.com;
  connect-src 'self' https://api.livechatinc.com https://cdn.livechatinc.com;
  img-src 'self' data: https:;
  style-src 'self' 'unsafe-inline';
  frame-src https://secure.livechatinc.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';

You’ll need to adjust this to match the exact widget endpoints you use. Start with Report-Only, watch violations, then tighten it. For CSP implementation details, https://csp-guide.com is a good reference.

That said, CSP does not make innerHTML safe. If your own code injects attacker-controlled HTML, you still have a bug.

Don’t trust admin-only surfaces

Internal dashboards are where chat XSS often becomes a stored XSS issue:

  • support inboxes
  • CRM sidebars
  • ticket sync views
  • QA transcript reviewers
  • analytics panels

The attacker sends a payload as a visitor message, or sneaks it into a name/custom field, and your staff triggers it later in a privileged app. That’s often worse than a public-page XSS.

Treat every field from chat APIs and webhooks as hostile, even in internal tools.

A minimal safe integration pattern

If you want a baseline approach, this is the one I’d start with:

<script>
  function cleanText(value, max = 100) {
    if (typeof value !== "string") return "";
    return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, max);
  }

  function cleanEmail(value) {
    const v = cleanText(value, 254);
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? v : "";
  }

  window.__lc = window.__lc || {};
  window.__lc.visitor = {
    name: cleanText(window.appUser?.displayName, 80),
    email: cleanEmail(window.appUser?.email)
  };

  window.__lc.custom_variables = [
    { name: "account_id", value: cleanText(window.appUser?.accountId, 40) },
    { name: "plan", value: cleanText(window.appUser?.plan, 20) }
  ];
</script>

And for your own UI:

function setText(selector, value, fallback = "") {
  const el = document.querySelector(selector);
  if (el) el.textContent = value || fallback;
}

setText("#chat-user-name", window.appUser?.displayName, "Anonymous");
setText("#chat-user-email", window.appUser?.email, "No email");

Boring code. That’s the point.

Quick checklist

Use this before shipping a LiveChat widget integration:

  • Validate and length-limit visitor fields before passing them in
  • Never trust URL params, campaign tags, or CRM values
  • Never use innerHTML for visitor names, emails, or messages
  • Escape chat data when rendering server-side HTML
  • Keep template auto-escaping enabled
  • Treat webhook and API payloads as untrusted input
  • Don’t store HTML in custom variables
  • Add CSP and test in report-only mode first
  • Review internal support/admin tools for stored XSS paths

If your widget integration touches user input, assume someone will try <svg/onload=alert(1)> sooner or later. Build like you already know that, and the whole class of bugs gets much easier to kill.