Discord bots themselves do not execute browser JavaScript inside Discord messages, and that lulls a lot of developers into the wrong threat model.
I’ve seen this play out the same way over and over: someone builds a bot, then adds a web dashboard, moderation panel, transcript viewer, ticket system, or message log page. The bot becomes the source of untrusted content, and the browser-facing parts become the XSS sink.
That distinction matters. The bug usually is not “Discord has XSS.” The bug is “my bot collected hostile input from Discord and I rendered it into HTML like an idiot.”
Here are the mistakes I see most often, and how to fix them.
Mistake 1: Treating Discord content as trusted text
Every field coming from Discord users is untrusted:
- message content
- usernames
- global display names
- nicknames
- custom status text copied into logs
- embed titles and descriptions if users can influence them
- attachment filenames
- modal input
- slash command options
- forum post titles
- ticket subjects
A very common bug looks like this:
app.get('/logs', async (req, res) => {
const messages = await getRecentMessages();
const html = messages.map(msg => `
<div class="message">
<strong>${msg.authorName}</strong>
<p>${msg.content}</p>
</div>
`).join('');
res.send(`
<html>
<body>${html}</body>
</html>
`);
});
If msg.content contains <img src=x onerror=alert(1)>, you just turned a Discord message into script execution in your admin panel.
Fix
Escape output based on context. If you are rendering into HTML text nodes, HTML-encode it.
function escapeHtml(str = '') {
return str
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
app.get('/logs', async (req, res) => {
const messages = await getRecentMessages();
const html = messages.map(msg => `
<div class="message">
<strong>${escapeHtml(msg.authorName)}</strong>
<p>${escapeHtml(msg.content)}</p>
</div>
`).join('');
res.send(`
<html>
<body>${html}</body>
</html>
`);
});
Better yet, use a templating engine that escapes by default instead of building HTML with string concatenation.
Mistake 2: Rendering markdown as raw HTML without sanitizing
Bot dashboards love “pretty transcripts.” Developers often convert Discord-flavored markdown into HTML and dump it straight into the page.
That is dangerous if your markdown renderer allows raw HTML or unsafe URLs.
Example of the bad pattern:
import { marked } from 'marked';
app.get('/transcript/:id', async (req, res) => {
const transcript = await getTranscript(req.params.id);
const rendered = marked.parse(transcript.content);
res.send(`<div class="transcript">${rendered}</div>`);
});
If your renderer passes through HTML, an attacker can inject tags directly. Even if HTML is blocked, javascript: URLs and event-handler tricks can still show up depending on the renderer and sanitizer setup.
Fix
Sanitize rendered HTML after markdown conversion, or disable raw HTML support entirely if the renderer supports it. Sanitizing is not optional here.
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
app.get('/transcript/:id', async (req, res) => {
const transcript = await getTranscript(req.params.id);
const rendered = marked.parse(transcript.content);
const safeHtml = DOMPurify.sanitize(rendered);
res.send(`<div class="transcript">${safeHtml}</div>`);
});
If you control the UX, I’d rather show plain text than maintain a fragile markdown-to-HTML pipeline.
Mistake 3: Assuming embeds are harmless because they are “structured”
Discord embeds feel safer because they have fields like title, description, and footer. People assume structure equals safety. It does not.
If a user can influence an embed field and you later display it in a dashboard, transcript, or audit page, it is still untrusted input.
Bad example:
function renderEmbed(embed) {
return `
<section class="embed">
<h3>${embed.title}</h3>
<div>${embed.description}</div>
<small>${embed.footer?.text || ''}</small>
</section>
`;
}
Fix
Escape every field unless you have a very specific reason not to.
function renderEmbed(embed) {
return `
<section class="embed">
<h3>${escapeHtml(embed.title || '')}</h3>
<div>${escapeHtml(embed.description || '')}</div>
<small>${escapeHtml(embed.footer?.text || '')}</small>
</section>
`;
}
If you want rich formatting, sanitize a narrow subset of allowed tags and attributes. Narrow means narrow.
Mistake 4: Injecting user content into JavaScript blocks
This one is sneaky. Developers escape HTML, then break everything by embedding user data into inline scripts.
res.send(`
<script>
const username = "${user.username}";
const lastMessage = "${message.content}";
</script>
`);
A username containing "; alert(1); // is enough to break out of the string.
Fix
Do not inject untrusted data into inline JavaScript. Serialize it safely.
res.send(`
<script type="application/json" id="boot">
${JSON.stringify({
username: user.username,
lastMessage: message.content
}).replace(/</g, '\\u003c')}
</script>
`);
Then parse it on the client:
const boot = JSON.parse(document.getElementById('boot').textContent);
Even better, avoid inline scripts entirely and fetch JSON from an API endpoint.
Mistake 5: Unsafe use of innerHTML in bot dashboards
Frontend code for bot panels often takes API data and drops it into the DOM with innerHTML.
fetch('/api/messages')
.then(r => r.json())
.then(messages => {
document.getElementById('messages').innerHTML = messages.map(msg => `
<li><strong>${msg.author}</strong>: ${msg.content}</li>
`).join('');
});
That is classic DOM XSS.
Fix
Use textContent and DOM APIs.
fetch('/api/messages')
.then(r => r.json())
.then(messages => {
const list = document.getElementById('messages');
list.innerHTML = '';
for (const msg of messages) {
const li = document.createElement('li');
const strong = document.createElement('strong');
strong.textContent = msg.author;
li.appendChild(strong);
li.append(`: ${msg.content}`);
list.appendChild(li);
}
});
If you absolutely must render HTML, sanitize first and keep the allowed markup tiny.
Mistake 6: Building links from untrusted URLs
Bots often log attachment URLs, profile links, external references, or user-submitted “website” fields. Developers then render them directly into href.
return `<a href="${user.website}">Website</a>`;
If user.website is javascript:alert(1), your HTML escaping won’t save you. This is not an HTML problem. It is a URL validation problem.
Fix
Validate allowed schemes and normalize URLs before rendering.
function safeUrl(input) {
try {
const url = new URL(input);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return url.toString();
}
} catch {}
return null;
}
const url = safeUrl(user.website);
const linkHtml = url
? `<a href="${escapeHtml(url)}" rel="noopener noreferrer">Website</a>`
: `<span>No website</span>`;
For admin panels, I’m pretty strict here: allow only http and https, nothing else.
Mistake 7: Trusting filenames and attachment metadata
Attachment filenames are user-controlled. If you show them in HTML without escaping, they are another XSS vector.
return `<li><a href="${file.url}">${file.name}</a></li>`;
Same problem, two places: URL and visible text.
Fix
Validate the URL and escape the filename.
const fileUrl = safeUrl(file.url);
return fileUrl
? `<li><a href="${escapeHtml(fileUrl)}">${escapeHtml(file.name)}</a></li>`
: `<li>${escapeHtml(file.name)}</li>`;
Also be careful if you mirror uploads to your own domain. Serving user files from the same origin as your dashboard is asking for trouble.
Mistake 8: No CSP on the dashboard
Content Security Policy will not fix bad rendering logic, but it does reduce blast radius when something slips through. Most bot dashboards have weak or nonexistent CSP, and that is a mistake.
A practical baseline CSP for a bot admin panel might block inline script and restrict script sources to self. If you need help implementing it cleanly, https://csp-guide.com is useful.
In Express with Helmet:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", "https:", "data:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"]
}
}
}));
Read the official Helmet docs and tune it for your asset pipeline and frontend framework.
Mistake 9: Thinking “admin-only” means low risk
A lot of bot dashboards are internal tools for moderators or server admins. That makes XSS worse, not better.
If an attacker can post payloads in a public channel and get them rendered in a moderator dashboard, they may be able to:
- steal moderator session tokens
- trigger privileged API actions
- read sensitive ticket logs
- abuse bot management features
- pivot into linked guild management functions
Admin-facing XSS is high-value. Treat it that way.
A safer pattern for Discord bot UIs
My default advice is boring because boring works:
- store raw Discord content as data
- escape on output by context
- prefer server-side templates with auto-escaping
- avoid
innerHTML - sanitize any HTML you intentionally allow
- validate URLs separately from HTML escaping
- use CSP as backup, not primary defense
If your bot has a dashboard, transcript viewer, or moderation log page, assume attackers will intentionally feed it payloads through Discord. Because they will.
For framework-specific details, stick to official docs for your stack and Discord’s official developer docs for bot data handling. The XSS part is not Discord-specific. The dangerous part is what you do after Discord hands user-controlled content to your app.