Chat apps are XSS magnets. That is not a criticism of Stream Chat specifically — it is just the reality of any product that renders user-generated content in real time, across devices, often with rich formatting layered on top.
If you use Stream Chat, the core question is not “does Stream escape content?” The real question is “where can my app accidentally turn safe chat data into executable code?”
That distinction matters. I have seen teams assume the chat SDK is the security boundary, then quietly reintroduce XSS through custom message rendering, markdown, link previews, emoji plugins, or attachment handling.
Here’s the comparison guide I wish more teams had before shipping chat.
The short version
There are two broad ways developers handle message rendering in Stream Chat apps:
- Plain-text or framework-escaped rendering
- Rich HTML rendering with custom formatting
The first is dramatically safer. The second can be fine, but only if you are disciplined about sanitization, output encoding, and CSP.
Option 1: Plain-text rendering
This is the boring option, which is exactly why I like it.
If messages are rendered as text nodes, or through framework templates that escape by default, the browser treats attacker input as text rather than markup.
Example: safe React rendering
function MessageText({ message }) {
return <div className="message-text">{message.text}</div>;
}
If message.text contains this:
<img src=x onerror=alert(1)>
React escapes it before rendering. Users see the string, not a popup.
Pros
- Strong default safety against classic reflected and stored XSS
- Simple mental model
- Less sanitizer complexity
- Easier to review in code review
- Fewer CSP exceptions needed
Cons
- Limited formatting
- No arbitrary inline HTML
- Developers often try to “enhance” it later, which is where problems begin
Best use case
Use this if your chat product does not truly need HTML. Most apps do not. Mentions, emoji, code blocks, and links can usually be implemented without ever trusting raw HTML from users.
Option 2: Rendering rich HTML in messages
This is where things get messy.
Some teams want markdown, custom embeds, styled content, or imported message history that already contains HTML. The temptation is to do something like this:
function RichMessage({ message }) {
return (
<div
className="message-text"
dangerouslySetInnerHTML={{ __html: message.html }}
/>
);
}
That code is not “risky.” It is a direct XSS sink.
If message.html comes from a user, or from any pipeline that can be influenced by a user, you are one sanitizer bug away from a stored XSS issue that hits every recipient in the channel.
Pros
- Flexible formatting
- Supports richer message experiences
- Useful for imported or transformed content
- Can improve UX for support, community, or collaboration products
Cons
- High XSS risk
- Sanitizer configuration becomes security-critical
- Custom components often bypass the sanitizer
- Attachment captions, quoted replies, previews, and edited messages become extra attack surfaces
- Harder to reason about over time
My opinion
If you need HTML rendering, treat it like handling file uploads or auth tokens: high-risk and worthy of strict architecture. Do not let random UI code decide how to sanitize.
Where XSS actually shows up in Stream Chat apps
The Stream Chat SDK and components help, but developers usually create the vulnerability in the layers around them.
1. Custom message components
This is the most common source of trouble.
You start with a safe message component, then add custom formatting:
function CustomMessage({ message }) {
const html = parseMarkdownToHtml(message.text);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Now your markdown parser and sanitizer are part of your security model.
2. Attachment rendering
If users can upload files, share URLs, or attach custom payloads, check every field you render:
- filename
- title
- alt text
- captions
- Open Graph metadata
- custom attachment fields
A title rendered safely as text is fine:
<h4>{attachment.title}</h4>
A title rendered as HTML is not:
<h4 dangerouslySetInnerHTML={{ __html: attachment.title }} />
3. Link previews and embeds
Link unfurling is convenient and dangerous. If your app fetches remote metadata and renders it into the DOM, you need to distrust it just like user input.
A surprising number of developers trust preview metadata because it came from “the server.” That is a mistake. If the metadata came from an attacker-controlled page, it is attacker-controlled input.
4. Markdown support
Markdown is not automatically safe. It often becomes HTML before rendering, and HTML is where XSS happens.
Even if you disable raw HTML in markdown, you still need to think about:
- dangerous URLs in links
- image sources
- autolink behavior
- custom markdown extensions
- syntax highlighting plugins
5. Mentions, commands, and slash features
Anything that converts text into richer UI can create edge cases. A mention system that inserts usernames safely is fine. A command system that injects HTML fragments into the transcript is not.
Safe pattern vs risky pattern
Here is the comparison that matters most.
Safer pattern: structured rendering
Instead of storing HTML, store structured message data and render known-safe components.
{
"text": "Check this issue",
"mentions": ["alice"],
"link": {
"url": "https://example.com/ticket/123",
"label": "Ticket #123"
}
}
Then render with explicit components:
function StructuredMessage({ message }) {
return (
<div>
<span>{message.text}</span>
{message.link && (
<a href={message.link.url} rel="noopener noreferrer">
{message.link.label}
</a>
)}
</div>
);
}
Pros
- Much lower XSS exposure
- Easier validation
- Cleaner permission boundaries
- Better compatibility with CSP
Cons
- More engineering work up front
- Less flexible than arbitrary HTML
- Requires schema discipline
Risky pattern: store and replay HTML
{
"html": "<p>Hello <strong>world</strong></p>"
}
This seems easy until the exceptions start piling up. Then someone adds inline styles, then custom embeds, then one sanitizer bypass becomes a stored XSS incident.
If you must render HTML, do it like you mean it
Sometimes you really do need HTML. If so, put hard rules in place.
1. Sanitize on the server
Do not rely only on client-side sanitization. Server-side sanitization gives you one policy, one choke point, and one place to test.
2. Sanitize again if needed at render time
Defense in depth is worth it for high-risk content. Especially if messages can be edited, migrated, transformed, or enriched after storage.
3. Allowlist tags and attributes
Keep the allowed set tiny. Most chat apps need very little:
b,strongi,emcode,prea- maybe
p,br,ul,ol,li
Be extremely cautious with:
styleiframesvg- event handlers
- custom data attributes used by JS
4. Validate URLs
A lot of “sanitized” content still fails on URL handling. Reject or rewrite dangerous schemes:
javascript:data:where unnecessaryvbscript:
5. Use CSP as backup, not as the primary fix
A strong Content Security Policy can reduce blast radius when rendering goes wrong. I strongly recommend one for chat apps because stored XSS is such a common failure mode.
A decent baseline often includes:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
If you need help rolling that out, https://csp-guide.com is useful. For Stream-specific behavior and component usage, stick to the official docs at https://getstream.io/chat/docs/.
Pros and cons of relying on Stream components
If you use Stream’s prebuilt UI components and avoid overriding rendering paths, you usually get a safer starting point than building everything yourself.
Pros
- Better defaults than many custom chat builds
- Less direct DOM manipulation
- Fewer chances to introduce raw HTML sinks
- More predictable rendering behavior
Cons
- Teams often override the safe parts first
- Security assumptions get lost in customization
- Third-party plugins can weaken the model
- Prebuilt does not mean immune
My rule is simple: every time you customize message rendering, assume you are editing the XSS boundary.
Practical checklist for Stream Chat XSS prevention
Use this before launch:
- Render message text as plain text whenever possible
- Avoid
dangerouslySetInnerHTML - Do not store raw HTML unless you have a strong reason
- Sanitize rich content on the server
- Treat link preview metadata as untrusted input
- Validate all URLs before rendering
- Review attachment rendering paths
- Lock down markdown features
- Deploy a strict CSP
- Test edited messages, quoted messages, threads, reactions, and system messages
- Fuzz with payloads like:
<img src=x onerror=alert(1)><svg/onload=alert(1)>[click](javascript:alert(1))
My recommendation
For most Stream Chat apps, the best tradeoff is:
- plain-text storage
- structured metadata for rich features
- framework-escaped rendering
- strict CSP
That gives you rich enough chat without turning every message into a tiny HTML document.
If you choose full HTML rendering, accept the cost honestly. You will need sanitizer policy ownership, security testing, and ongoing maintenance. That is not overengineering. That is the price of letting users send markup to other users.