Open a Telegram Mini App and you'll never see a login step. The page loads and it already knows who you are.

That's convenient, and it's easy to get wrong. Our small team builds web games that run inside Telegram, so this code sits in front of every request we handle. Here's what a Mini App really receives, how to verify it, and the traps along the way.

What your app gets on launch

Telegram hands the page a signed query string in Telegram.WebApp.initData. Decoded, a typical one holds:

Field What it is
user JSON: id, first_name, optional last_name, username, language_code, is_premium, allows_write_to_pm, photo_url
auth_date Unix time the app was opened
query_id Session id for answering through the bot, when present
start_param Whatever your deep link carried
chat_type, chat_instance Where a direct link was opened from
chat, receiver Chat details, only for certain launches such as the attachment menu
hash, signature Telegram's proofs that the rest is real

Your server also sees the user's IP address, like any website.

What isn't there matters just as much. There's no phone number: requestContact() raises a native pop-up the user has to accept. Permission to message them and location work the same way. And Telegram's docs describe no way for a Mini App to read chat history.

Three details worth knowing before you write any code:

  • user is optional, and initData is empty when the app is launched from a keyboard button or inline mode. Don't assume it's there.
  • user.id can run past 32 bits (Telegram says up to 52). Store it in a 64-bit column.
  • Telegram.WebApp.initDataUnsafe is the same data, already parsed, with nothing checked. The docs say it plainly: data from this field should not be trusted. It's fine for showing a name in the header. It must never decide who a request belongs to.

Verifying it on the server

The client sends the raw initData string, and the server recomputes the signature:

  1. Take every field except hash, sort by key, join as key=value with newlines.
  2. Derive a secret: HMAC-SHA-256 of your bot token, keyed with the literal string WebAppData.
  3. HMAC-SHA-256 the string from step 1 with that secret, and compare the hex to hash.

Here it is in Node with no dependencies:

import { createHmac, timingSafeEqual } from "node:crypto"; const MAX_AGE_SECONDS = 24 * 60 * 60; function parseFields(initData) { const fields = new Map(); for (const pair of initData.split("&")) { const at = pair.indexOf("="); if (at < 1) return null; const key = decodeURIComponent(pair.slice(0, at)); // A repeated key is never legitimate, so refuse the whole string. if (fields.has(key)) return null; fields.set(key, decodeURIComponent(pair.slice(at + 1))); } return fields; } export function verifyInitData(initData, botToken, now = Date.now() / 1000) { let fields; try { fields = parseFields(initData); } catch { return null; // malformed percent-encoding } const hash = fields?.get("hash"); if (!hash) return null; const dataCheckString = [...fields] .filter(([key]) => key !== "hash") .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); const secret = createHmac("sha256", "WebAppData").update(botToken).digest(); const expected = createHmac("sha256", secret).update(dataCheckString).digest(); const received = Buffer.from(hash, "hex"); if (received.length !== expected.length || !timingSafeEqual(received, expected)) { return null; } const age = now - Number(fields.get("auth_date")); if (!(age <= MAX_AGE_SECONDS && age > -60)) return null; // Read the user from the fields that were just verified, and nowhere else. return fields.has("user") ? JSON.parse(fields.get("user")) : null; } 

Some traps this avoids:

  • The HMAC argument order. The docs write HMAC_SHA256(<bot_token>, "WebAppData"), but WebAppData is the key and the token is the message. Swap them and every request fails.
  • signature stays in. Newer payloads include a signature field, and it remains part of the string for this check. Only hash comes out. (signature is an Ed25519 proof that lets a third party verify the data without your bot token. That check leaves it out, so the two strings differ.)
  • auth_date is your only expiry. The hash proves Telegram issued the data, not that it was issued recently. Without an age check, one leaked string works forever. We use 24 hours.
  • Parse once. The identity you act on has to be the identity you verified. If the validator and a "get the current user" helper each parse the string, a repeated user= key can make them disagree: one parser keeps the last value and the other takes the first. That's why parseFields refuses duplicates, and why the user comes out of the same Map the hash was computed over.

A few more things worth doing

Send it in a header, not the URL. URLs end up in access logs and analytics, and initData stays valid for as long as your window allows. We send it as X-Telegram-Init-Data.

auth_date never refreshes. It's stamped at launch and frozen for the life of the webview, so a tab left open past your window starts failing. Return a distinct error for "authentic but expired" and show "reopen the app". Keep every other failure as one generic 401, so a caller can't learn which check they tripped.

Be strict about the dev bypass. Everyone adds a "skip the signature locally" switch. Checking that the environment is called Development isn't enough, because a misconfigured deployment can claim that too. Ours needs an explicit config flag and a real loopback connection, and it ignores forwarded headers.

Trust stops at what users type. Everything above protects your server from fake users. Users have the opposite problem, because security researchers report that Telegram does almost no vetting of Mini Apps before they go live, and a page inside Telegram looks official. Telegram already knows who they are, so a real app has no reason to ask for a login code. For anyone non-technical who asks, here's a plain-language guide to what Mini Apps can see and which scams to watch for.

If you've found other ways to get this wrong, I'd like to hear them in the comments.