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 handles | Stays yours | How you find out it is not done |
|---|---|---|
| The sign-in screen, the password, account recovery | Verifying the hash with WebAppData as the HMAC key, not the token | Every authenticated request 401s from the first deploy |
Signing auth_date into the payload | Choosing how long a string stays valid, and rendering the expired state | A string from last week still works; a leaked one works too |
| Signing which bot issued the data | Resolving the verification key from your own state, never the request | An attacker’s forged initData verifies against their bot |
| Telling you who the user is | Sessions, roles, per-row authorisation, the user table | One authenticated user reads another’s data |
| Nothing about storage | A 64-bit column for an ID that exceeds 32 bits | IDs collide or wrap once you have enough users |
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
- Telegram Mini Apps
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.
- Telegram Login Widget
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.
- @tma.js/init-data-node: Validating
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 }.
- Telegram Bot API: User
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.