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:
-
useris optional, andinitDatais empty when the app is launched from a keyboard button or inline mode. Don't assume it's there. -
user.idcan run past 32 bits (Telegram says up to 52). Store it in a 64-bit column. -
Telegram.WebApp.initDataUnsafeis 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:
- Take every field except
hash, sort by key, join askey=valuewith newlines. - Derive a secret: HMAC-SHA-256 of your bot token, keyed with the literal string
WebAppData. - 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"), butWebAppDatais the key and the token is the message. Swap them and every request fails. -
signaturestays in. Newer payloads include asignaturefield, and it remains part of the string for this check. Onlyhashcomes out. (signatureis 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_dateis 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 whyparseFieldsrefuses duplicates, and why the user comes out of the sameMapthe 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.
Top comments (0)