How to Validate initData in Telegram Mini Apps on the Backend
Advanced4 minInnovialabs editorial team
In short
Send the raw initData string to your server, recompute HMAC-SHA-256 with a key derived from the bot token and the string WebAppData, compare it with hash and check auth_date — never trust initDataUnsafe.
The short answer
When a Mini App opens, Telegram passes it initData — a query-string-formatted value with user data and a hash signature. Your backend must:
- Receive the raw string
Telegram.WebApp.initDatafrom the client. - Recompute the signature with the bot token and compare it with
hash. - Check that
auth_dateis fresh enough. - Only then read
user.idand create a session.
Why initDataUnsafe cannot be trusted
Telegram.WebApp.initDataUnsafe is the same data, already parsed into an object on the client. The word Unsafe is an explicit warning:
- A Mini App is an ordinary web page. Anyone can open it in a browser, replace
window.Telegram, or edit the request in DevTools. - Anyone can send
{"user": {"id": 123}}to your API and impersonate another user. initDataUnsafeis fine for display only: a name in the header, an avatar before data loads. For authorization, payments and data access, use verifiedinitDataonly.
The signature algorithm
- Parse
initDataas a query string. Set aside thehashvalue. - Sort the remaining pairs by key and join them as
key=valuelines separated by\n. Use values decoded but otherwise untouched — do not rebuild the JSON in theuserfield. - Secret key:
HMAC-SHA-256(key="WebAppData", message=bot_token). - Expected signature:
hex(HMAC-SHA-256(key=secret_key, message=data_check_string)). - Compare it with
hashin constant time.
import crypto from "node:crypto";
export function validateInitData(initData, botToken, maxAgeSec = 3600) {
const params = new URLSearchParams(initData);
const hash = params.get("hash");
if (!hash) return null;
params.delete("hash");
const dataCheckString = [...params.entries()]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, v]) => `${k}=${v}`)
.join("\n");
const secret = crypto.createHmac("sha256", "WebAppData").update(botToken).digest();
const expected = crypto.createHmac("sha256", secret).update(dataCheckString).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(hash, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const authDate = Number(params.get("auth_date"));
if (!authDate || Date.now() / 1000 - authDate > maxAgeSec) return null;
return JSON.parse(params.get("user") ?? "null");
}
Popular libraries ship ready-made implementations, such as safe_parse_webapp_init_data in aiogram or the @telegram-apps/init-data-node package. If you use one, still confirm that the expiry check is enabled.
auth_date freshness
A signature proves authenticity, not freshness. An intercepted initData string can be replayed for as long as you accept it.
- Set a maximum age that fits the scenario: short for payments, longer for browsing a catalog.
- Treat
initDataas a one-time pass: verify it, then issue your own session token with a clear lifetime. - Keep server time accurate (NTP), or fresh data will be rejected.
How to send initData to the server
- Put the string in a header, for example
Authorization: tma <initData>— a convention used by several ecosystem libraries. - Do not put
initDatain the URL: it will end up in proxy and analytics logs. - Verify the signature in one middleware, not separately in every handler.
Validation without the bot token
Sometimes a third-party service needs to verify user data without having your token. For this, initData includes a signature field — an Ed25519 signature that can be checked with Telegram's public key.
- The string to verify starts with
<bot_id>:WebAppData, followed by the sorted fields joined with\n, excludinghashandsignature. - Public keys for the production and test environments are published in the Telegram documentation.
- This way the service confirms the data was issued for your bot without ever receiving the token.
The bot token is full control over the bot. Never hand it to partners just so they can verify signatures.
Common mistakes
- Re-serializing the
userJSON: key order or escaping changes and the signature no longer matches. - Double decoding, or the opposite — using still-encoded values.
- Swapped HMAC arguments: the secret is the HMAC of the token keyed with
WebAppData, not the other way around. - Checking the signature but not
auth_date. - A different token in test and production for the same bot.