A Telegram Mini App does not have a login screen. The user is already signed in to Telegram, so when they open your app the client hands your backend a string called initData: a query string carrying the user’s ID, name, and a hash Telegram computed over the rest. No password, no OAuth round trip, no email verification, no account-recovery flow to build. Telegram removed the entire front of authentication.

What it did not remove is the check. That hash is only worth something if your server verifies it, on every request, against the right key, within a window you decide. We built volta’s Mini App auth on Telegram and that verification is the part we wrote, test, and get paged about. Here is the shape of it.

The bot token is the message, not the key

Telegram’s verification recipe reads backwards the first time. The secret key is “the HMAC-SHA-256 signature of the bot’s token with the constant string WebAppData used as a key”, and you then compare the received hash against the hex HMAC-SHA-256 of the data-check-string under that secret key.[1]

So the bot token is the data being signed, and the literal string WebAppData is the HMAC key. The intuitive version, where you HMAC the payload with the bot token as the key, passes a code review and fails every real request. So does plain SHA256(bot_token), which is not a wrong guess out of nowhere: it is exactly how Telegram’s other sign-in surface, the Login Widget, derives its key.[2] Two Telegram auth mechanisms, two derivations, and volta carries a verifier for each because a creator can sign in through either. They are not interchangeable and nothing tells you when you have used the wrong one except a wall of 401s.

The data-check-string has its own trap. It is every received field except hash, sorted alphabetically, joined with a line feed, formatted key=value.[1] The user field’s value is a JSON blob with its own braces and quotes, and it goes into the string verbatim, exactly as received. Parse it, re-serialise it, and re-insert it and the bytes change, the HMAC changes, and the check fails on every account whose name has a character your JSON encoder escapes differently from Telegram’s.

The string has no expiry until you give it one

initData carries auth_date, a Unix timestamp of when Telegram issued it.[1] Nothing rejects an old one for you. A Mini App runs in a webview that a user can leave open for days, background, and come back to, still holding the initData from Tuesday. If your only check is the HMAC, that string authenticates forever, and a copy pulled from a proxy log authenticates just as well.

volta pins the window at 24 hours. We verify through @tma.js/init-data-node, whose validate function checks auth_date against a default of 86,400 seconds unless you pass { expiresIn: 0 } to turn it off.[3] The default is sane; the point is that it is a default you are accepting, and the number is a product decision. A checkout Mini App that a buyer opens once has no reason to honour a day-old string. Whatever you pick, the expired case is now a state your UI has to handle: on volta the public offer page degrades to an anonymous, locked teaser when the string is stale rather than throwing a 401 at someone who just wanted to read the page.[1]

The verification key is server state, not request input

volta is a platform with many bots, one per creator. That means the code cannot hardcode a single bot token, and the tempting shortcut, reading a bot identifier from the request and looking up its token, is a spoofing hole: an attacker points you at a bot they control, signs their forged initData with that bot’s token, and your check passes.

The verification resolves the offer from the URL, loads the bot row that owns it from the database, decrypts that bot’s token, and verifies against it. The client never names the key. If you run one bot this collapses to a single environment variable and the risk disappears, but the principle is the same at any scale: the key side of the HMAC is something your server already knows, never something the caller supplied.

initDataUnsafe is right there, and it is a trap

The Telegram client SDK exposes two objects. initData is the raw signed string. initDataUnsafe is the same information already parsed into a convenient object, and Telegram’s own documentation is blunt about it: “Data from this field should not be trusted. You should only use data from initData on the bot’s server and only after it has been validated.”[1]

It is unsigned. Anything in the webview can write to it. It exists so the frontend can render a name before the backend answers, and every month someone ships a backend that reads initDataUnsafe.user.id off the request body because the field was sitting there with the right shape. The only value your server may act on is the id inside a raw initData string it verified itself.

Telegram gives you a claim, not a user table

A verified initData gets you a user object: an ID, a first name, maybe a username and language code. That is the whole identity. There is no session, no account row, no “is this user allowed to see this order” - Telegram signs who the user is and hands the rest to you.

So you build the rest. volta keys every buyer-scoped row on the Telegram user ID and every query that returns buyer data is scoped WHERE offer = ? AND buyer_tg_user_id = ?, because “the request is authenticated” and “the request is allowed to read this specific row” are different questions and the second one is entirely yours. We found two endpoints in review that had the first check and not the second, which is one authenticated buyer reading another’s purchase history.

The ID itself has a footgun worth one line of schema review. Telegram’s API notes that a user ID “may have more than 32 significant bits” and “at most 52”, and recommends a 64-bit integer or a double.[4] Store it in a 32-bit int column and it overflows silently for a growing share of real users. volta uses a 64-bit column and carries the ID as a bigint in code for the same reason.

The part that stays a cost

Telegram is now your identity provider, and it is also your distribution channel and your outage surface, and you cannot separate them. initData is only issued inside a Telegram client, so there is no Mini App sign-in from a plain browser at all - that is the reason volta needs a public read path with its own rules rather than one auth check in front of everything. If Telegram is down, nobody signs in, and there is no fallback login because deleting the login screen was the point. And you can only reach a user through the channel Telegram allows: no email unless they gave your bot one, no “reset your password” because there is no password to reset.

Telegram handlesStays yoursHow you find out it is not done
The sign-in screen, the password, account recoveryVerifying the hash with WebAppData as the HMAC key, not the tokenEvery authenticated request 401s from the first deploy
Signing auth_date into the payloadChoosing how long a string stays valid, and rendering the expired stateA string from last week still works; a leaked one works too
Signing which bot issued the dataResolving the verification key from your own state, never the requestAn attacker’s forged initData verifies against their bot
Telling you who the user isSessions, roles, per-row authorisation, the user tableOne authenticated user reads another’s data
Nothing about storageA 64-bit column for an ID that exceeds 32 bitsIDs collide or wrap once you have enough users
volta's Telegram Mini App auth, the seam by line item.

None of this is an argument against building on Telegram. Removing passwords, credential storage, and the account-recovery flow is a large, genuine reduction in what you have to build and secure, and no HMAC bug in this article is as dangerous as rolling your own password reset. The point is narrower: the signed string is the easy 80% of auth handed to you, and the verification, the lifetime, the key handling, and the entire authorisation layer above it are still an integration you own. Scope it as one, not as a checkbox.

If you are about to build on Telegram and want that seam scoped properly the first time, tell us what you are building - the product, the budget range, the date it needs to be live. You get a scope and a price back, or you get told we are the wrong fit for it.

Sources

  1. Telegram Mini Apps - Telegram , accessed

    Supports: Telegram signs a Mini App's initData with a secret key that is the HMAC-SHA256 of the bot token using the constant string WebAppData as the key; the data-check-string is every received field except hash, sorted alphabetically, joined as key=value with a line-feed (0x0A) separator; the data is valid only if the received hash equals the hex HMAC-SHA256 of that string; auth_date is a Unix timestamp the integrator can check to reject outdated data; data from initDataUnsafe must not be trusted and only validated initData may be used on the server; and a separate signature field is an Ed25519 signature for third parties that do not hold the bot token.

  2. Telegram Login Widget - Telegram , accessed

    Supports: Telegram's Login Widget, its other sign-in surface, verifies its payload against a secret key derived as SHA256 of the bot token, which is a different derivation from the Mini App's keyed HMAC.

  3. @tma.js/init-data-node: Validating - telegram-mini-apps (tma.js) , accessed

    Supports: The validate function treats init data as valid for one day (86,400 seconds) by default, expects the raw init data query string plus the bot token, throws SignatureInvalidError when the hash does not match, and skips the expiry check only when passed { expiresIn: 0 }.

  4. Telegram Bot API: User - Telegram , accessed

    Supports: A Telegram user id may have more than 32 significant bits and at most 52, so a 64-bit integer or double-precision float is safe to store it; a 32-bit integer type is not.