· 5 min read

The Telegram Bot Security Mistake That Keeps Reappearing: Trusting User Input Too Early

A surprising number of Telegram bots become vulnerable not because of exotic attacks, but because they treat incoming chat data as if it were already safe. This tutorial shows the common mistake, why it happens, and how to structure your bot so untrusted input never reaches privileged actions without validation.

Telegram bots are usually written as event-driven systems: a message arrives, a handler runs, and some action happens. That simplicity is nice, but it also creates a common security bug:

Developers use user-controlled text, callback data, usernames, or chat IDs directly in privileged logic before validating that the request is actually authorized.

This mistake shows up in many forms, but the underlying issue is the same: the bot treats data from Telegram as proof of identity, intent, or permission.

This tutorial focuses on that failure mode, why it is dangerous, and how to fix it in a practical way.

The core rule: Telegram input is not trust

Every piece of data coming from an update should be treated as untrusted until your code has checked it against your own rules.

That includes:

  • message text
  • callback_query.data
  • usernames
  • from.id
  • chat.id
  • forwarded message metadata
  • deep-link payloads
  • inline query strings

Telegram tells you who sent the update, but it does not tell you whether that user should be allowed to perform the action your bot is about to execute.

A common vulnerable pattern

Imagine an admin bot that deletes a resource when a user clicks a button.

@bot.callback_query_handler(func=lambda call: True)
def handle_action(call):
    resource_id = call.data
    delete_resource(resource_id)
    bot.answer_callback_query(call.id, "Deleted")

At first glance this seems fine. The button came from your bot, so surely call.data is trustworthy, right?

Not quite.

Why this is dangerous

  1. Callback data is user-controlled at the protocol level.

A malicious client can send arbitrary callback data to your bot.

  1. The bot never checks authorization.

Anyone who can trigger this handler can attempt to delete anything the bot knows about.

  1. The action is performed before any validation.

The dangerous line is the one that does the privileged work first.

Even if Telegram clients normally hide buttons from users who never saw them, your server should still treat callback payloads as attacker input.

The specific mistake: using identifiers from the update as authority

A subtle version of this bug happens when developers do something like:

  • trust chat.id because the message came from a group
  • trust from.id because it looks like a Telegram user identity
  • trust callback_query.message.chat.id because it was attached to the message
  • trust a deep-link parameter because the bot generated the link

These values may be useful for lookup, but they are not permission checks.

For example:

if call.from_user.id == ADMIN_ID:
    approve_invoice(call.data)

This is better than no check, but it is still fragile if call.data points to an object that belongs to another tenant, another chat, or a stale session. Identity alone is not enough if the action depends on context.

What attackers typically do

Attackers rarely need to break Telegram itself. They usually exploit your assumptions.

1. Forge or replay a callback payload

If your bot accepts an ID like delete:42, the attacker can try different values until they find one that works.

2. Send input that was supposed to come only from your UI

A common assumption is: “Only the button can send this value.” That is unsafe. The bot must verify whether the request is valid, not whether it looks familiar.

3. Mix contexts

A user may take a callback token intended for one chat or one session and reuse it elsewhere.

4. Exploit stale state

If you keep state in memory or in a database without expiration, old tokens and old message references may remain usable long after they should have stopped working.

A safer design: validate before action

The fix is not just “check the user.” The fix is to make every privileged handler follow the same shape:

  1. parse input
  2. validate input format
  3. authenticate the actor
  4. authorize the action in the current context
  5. execute the side effect
  6. log the decision

Let’s rewrite the earlier example more safely.

@bot.callback_query_handler(func=lambda call: True)
def handle_action(call):
    try:
        action, resource_id = call.data.split(":", 1)
        if action != "delete":
            raise ValueError("unknown action")
    except ValueError:
        bot.answer_callback_query(call.id, "Invalid request")
        return

    user_id = call.from_user.id
    if not is_admin(user_id):
        bot.answer_callback_query(call.id, "Not allowed")
        return

    resource = load_resource(resource_id)
    if resource is None:
        bot.answer_callback_query(call.id, "Not found")
        return

    if resource.owner_chat_id != call.message.chat.id:
        bot.answer_callback_query(call.id, "Context mismatch")
        return

    delete_resource(resource_id)
    bot.answer_callback_query(call.id, "Deleted")

This version improves security because it verifies several things before deleting anything:

  • the payload has the expected format
  • the user is an admin
  • the resource exists
  • the resource belongs to the current context

Make callback data opaque

If you put raw object IDs into callback data, you make enumeration easier and your authorization model easier to bypass.

A better pattern is to use an opaque token that maps to server-side state.

Better approach

  • generate a random token
  • store it server-side with expiration
  • attach the token to the button
  • verify the token when the callback arrives
  • invalidate it after use if it is one-time

Example conceptually:

# When creating a button
state_token = create_action_token(user_id=admin_id, resource_id=resource.id, ttl_seconds=300)
button = InlineKeyboardButton("Delete", callback_data=f"del:{state_token}")

Then on callback:

action_state = load_action_token(token)
if not action_state or action_state.expired:
    reject()
if action_state.user_id != call.from_user.id:
    reject()
if action_state.chat_id != call.message.chat.id:
    reject()
execute(action_state.resource_id)
invalidate(action_state)

This removes the need to trust the visible payload itself.

Don’t forget message-based commands

This mistake is not limited to inline buttons.

Consider a command like:

@bot.message_handler(commands=["ban"])
def ban_user(message):
    target = message.text.split()[1]
    ban(target)

This is unsafe if target is treated as a username or ID without checking whether the sender is allowed to ban users in the current chat.

A safer handler:

  • checks the command is used in an allowed chat
  • verifies the sender has moderator rights
  • validates the target format
  • resolves the target to a known user in that chat
  • applies the ban only after all checks pass

Commands are user input too.

Watch for these Telegram-specific traps

Forwarded messages

Forwarded content can make a message appear more trustworthy than it is. Do not use forwarded metadata as proof of identity or approval.

If your bot uses start=<payload>, treat the payload as a public token, not a secret. Assume anyone can copy it.

Inline mode

Inline query text is entirely controlled by the user. Never use it to build queries, file paths, HTML, or shell commands without validation.

Markdown and HTML output

If you echo user input back into formatted messages, escape it properly. Otherwise, you may create broken messages or injection-like rendering issues.

A practical checklist for Telegram bot developers

Before any handler performs a side effect, ask:

  • Is the input parsed and validated?
  • Is the sender authorized for this specific action?
  • Does the action belong to the current chat or session?
  • Is the token fresh and single-use if it should be?
  • Am I using IDs only as lookup keys, not as proof of permission?
  • Could this payload have been replayed or guessed?
  • Have I logged the decision for audit purposes?

If the answer to any of these is “no,” stop and fix the design.

A good bot architecture separates concerns:

  • transport layer: receives Telegram updates
  • validation layer: parses and normalizes input
  • policy layer: decides whether the user may do this
  • business logic: performs the actual action
  • audit layer: records what happened

That separation makes it much harder to accidentally perform a privileged action too early.

Final takeaway

The most common Telegram bot security mistake is not a fancy exploit. It is a simple ordering bug: using untrusted update data before you have checked whether the actor and context are allowed.

If you remember one thing, remember this:

Telegram gives you data from users. Your bot must decide whether that data is valid, relevant, and authorized before it does anything important.

Build that habit into every callback, command, and deep-link handler, and your bot will avoid one of the most common and costly security failures.

← All posts