Skip to content
All articles

Validating Telegram Mini App initData on the server

Sep 16, 2026 · Backend · 3 min

·By Dimitri Pisarev

Everything a Telegram Mini App knows about its user arrives in one query string called initData, signed by Telegram with an HMAC your backend can verify in about fifteen lines. initDataUnsafe, the parsed convenience object, is attacker-controlled and must never be the basis for an authorization decision. Verify the signature server-side on every request that touches user data, and treat the verified user.id as the only identity your API accepts.

This is the whole discipline behind Mini App backends, and it is the one part of a shop bot that has no shortcut: the storefront can be beautiful, but if the order endpoint trusts a user id sent as JSON from the client, anyone with curl is a customer.

What does Telegram actually sign?#

Telegram.WebApp.initData is a URL-encoded query string, for example:

auth_date=1737000000&query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%7D&hash=47c2b7ec6b...

The hash parameter is the signature over all the other parameters. The verification algorithm from the Mini Apps docs, verbatim:

  1. Parse the query string, take hash out, remember it.
  2. Sort the remaining fields alphabetically, join them as key=value lines with \n.
  3. Compute secret_key = HMAC_SHA256(key="WebAppData", message=bot_token).
  4. Compute hex(HMAC_SHA256(key=secret_key, message=data_check_string)).
  5. Compare with the provided hash using a constant-time equality check.
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function validateInitData(initData: string, botToken: string): boolean {
  const params = new URLSearchParams(initData);
  const hash = params.get("hash") ?? "";
  params.delete("hash");
 
  const checkString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : 1))
    .map(([k, v]) => `${k}=${v}`)
    .join("\n");
 
  const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
  const calc = createHmac("sha256", secret).update(checkString).digest();
 
  const given = Buffer.from(hash, "hex");
  return given.length === calc.length && timingSafeEqual(given, calc);
}

After this passes, params.get("user") is a JSON-encoded user object; parse it and its id becomes the request's identity. Anything the Mini App needs beyond identity (cart contents, prices) is re-derived server-side, never read back from the client.

How fresh is fresh? The question the docs leave open#

The docs say you "can additionally check" auth_date, and then stop: no recommended window, no default. That silence is a decision you have to make. Every verification now proves is "this string was signed by Telegram at some point", not "this session is current". A practical ceiling is 24 hours, matching how long a user session in a Mini App meaningfully lasts; an order-placement endpoint can demand more. State your window in one place and test it, because a window that is too tight logs users out mid-cart and looks like a bug in your app, not a security choice.

Note

The user field inside initData is itself JSON serialized into a query-string value. Decode the query string first, then JSON.parse the user field; doing it in the wrong order is the most common "my validation always fails" report.

What the 10.2 domain lock covers, and what it does not#

Since Bot API 10.2 (enforced from 2026-07-20), Mini App JavaScript refuses to run outside the origin configured in BotFather. That lock stops someone from hosting a copy of your frontend and harvesting initData through it. It does nothing for your API: a forged request never runs your JavaScript at all. The domain lock protects the client; the HMAC check protects the server; you need both, and only one of them is optional for an attacker.

The failure modes worth testing once#

  1. Tampered field. Change the user id in initData, keep the hash: validation must fail. If it passes, your check string is built from the wrong fields.
  2. Replay. A captured valid initData replayed next week: passes the HMAC, fails a sane auth_date window. This is the check people skip.
  3. Wrong order. Build the check string without sorting: fails against Telegram's own valid data, which convinces people the algorithm is wrong rather than the sort. It is not.
  4. Trusting the client bill. A valid identity with price: 1 in the body where the catalog says 8.90: the HMAC is irrelevant here. Prices, totals, and product ids are server facts; the Mini App sends intent, not numbers.

Fifteen lines of crypto, one freshness window, and a rule that client-sent numbers are suggestions: that is the entire security model of a Mini App backend, and it holds up fine when it is actually enforced.