Anyone Can POST to Your Telegram Webhook — Verify It's Telegram
When you point Telegram at your bot's webhook URL, you've created an ordinary public HTTPS endpoint — and nothing between the internet and your container filters who is allowed to call it. The edge terminates TLS and routes by hostname; it doesn't know or care that only Telegram should be posting. So anyone who learns your URL can send handcrafted updates that look exactly like real ones. The fix is built into the Bot API and takes two lines: set a secret_token when you register the webhook, then reject any request whose X-Telegram-Bot-Api-Secret-Token header doesn't match.
What a forged update actually gets someone
Worth being concrete, because "anyone can call your webhook" sounds abstract until you map it to your own code.
An update is a JSON object your bot trusts. It contains the sender's user ID, the chat ID, and the message text — all supplied by whoever sent the request. So a forged update lets an attacker claim to be any user, including you.
That matters most for the pattern nearly every hobby bot uses:
if update.message.from_user.id == OWNER_ID:
# admin commands
That check is sound when the update genuinely came from Telegram, and worthless when anyone can assert from_user.id. Any bot with owner-only commands — restart, broadcast, database queries, "run this shell command" — is one forged POST away from having them called by a stranger.
The same applies to anything keyed on chat ID: sending messages to chats the attacker names, or corrupting your own state with events that never happened.
The mechanism
Telegram's setWebhook accepts a secret_token — "a secret token to be sent in a header X-Telegram-Bot-Api-Secret-Token in every webhook request, 1-256 characters. Only characters A-Z, a-z, 0-9, _ and - are allowed" (Bot API docs).
Generate a long random value, register it, and store it as an environment variable on the app — never in source, since that's how it ends up published.
Then check it on every request:
import hmac, os
SECRET = os.environ["WEBHOOK_SECRET"]
def is_from_telegram(request) -> bool:
header = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
return hmac.compare_digest(header, SECRET)
Use a constant-time comparison rather than ==. It costs nothing and removes a class of timing attack that's tedious to reason about otherwise.
Reject mismatches with a 401 and log them — a sudden run of failures is a useful signal that your URL is known to someone it shouldn't be.
Don't rely on the URL being secret
The tempting shortcut is treating the URL itself as the password: a long random path that nobody can guess.
It helps a little and shouldn't be your only defence, because URLs leak in mundane ways — a screenshot of the panel, a setWebhook call logged with its full query string, a CI configuration, a support conversation. And the generated hostname for an app is short and not designed to be a secret.
A random path is a reasonable extra layer. It is not a substitute for verifying the header, which is the check Telegram actually provides.
The ip_address parameter, and why it doesn't help here
setWebhook also accepts ip_address — "the fixed IP address which will be used to send webhook requests instead of the IP address resolved through DNS". That pins where Telegram sends from, which is useful if you can filter inbound traffic by source address.
You can't do that here. There's no IP allowlist at the edge — protections have to live in your app. You could compare the client IP in your handler, but behind a reverse proxy that means trusting a forwarded header, which is its own can of worms. The secret token is the check that works.
Telegram opens more connections than you'd guess
A hosting-specific detail worth setting deliberately: max_connections defaults to 40 simultaneous HTTPS connections to your webhook.
Forty concurrent requests is a lot for an app on a fraction of a vCPU, where your CPU allocation is a time budget shared by the whole container. If each update triggers a database write or an API call, a burst can saturate you.
Lower it when you register the webhook — 5 or 10 is plenty for most bots, and Telegram simply queues the rest. You'll rarely notice the difference except in stability.
Answer fast, work afterwards
Telegram retries deliveries it considers failed, so a handler that does the work before responding creates two problems at once: connections pile up against that max_connections ceiling, and retries produce duplicate handling of the same update.
The shape that avoids both:
- Verify the secret token.
- Return
200immediately. - Do the actual work asynchronously.
Keep an eye on step 3 across restarts — work queued in memory is lost when the container is recreated, which happens on every deploy. For anything that must not be dropped, record it before you acknowledge.
FAQ
Can someone else send fake updates to my Telegram bot?
Yes, if they know your webhook URL and you don't verify the secret token. Nothing filters who can reach a public endpoint.
How do I verify a webhook request is really from Telegram?
Set secret_token in setWebhook and compare the X-Telegram-Bot-Api-Secret-Token header on every request, using a constant-time comparison.
Is a random URL path enough?
No. It's a reasonable extra layer, but URLs leak through screenshots, logs, and configuration. Verify the header.
Does the ip_address parameter protect me?
Not on its own here. It pins Telegram's sending address, but there's no IP filtering at the edge to enforce it against.
How many connections will Telegram open?
Up to 40 by default. Lower max_connections if your bot does real work per update — a fractional CPU won't enjoy forty at once.
Why is my bot handling the same message twice?
Probably slow responses causing Telegram to retry. Acknowledge with a 200 first, then do the work.
Wrapping up
Switching a Telegram bot from polling to webhooks trades an outbound connection for an inbound endpoint, and that endpoint has the same exposure as anything else you publish. The Bot API hands you the right control for it — one header, one comparison — and it takes about as long to add as it takes to read this. Set the token, reject mismatches loudly, keep max_connections proportionate to what your bot can actually chew, and answer before you start working.