What this tutorial covers
When a Telegram Mini App opens, the client passes an initData string into the WebApp SDK. Your backend should treat that string as untrusted input: a forged hash is cheap to build if you forget any step. The official recipe, published under the Telegram docs, is:
- Parse
initDataas a query string and drophash. - Sort the remaining pairs by key.
- Join them with
\ninto adata_check_string. - Compute
HMAC-SHA-256(data_check_string, secret_key)wheresecret_key = HMAC-SHA-256("WebAppData", bot_token). - Compare the hex digest against
hashusing a timing-safe comparison. - Verify that
auth_dateis within an acceptable window.
I will walk through a single validateInitData function that does exactly that, then show how to wire it into a controller. I do not cover signature replay across different bots, the receipt API, or the Login Widget — those are different surfaces with different rules.
Parsing initData
initData is delivered as a percent-encoded query string. parse_str works, but it converts dots in keys into underscores, which breaks keys like tg_start_param on PHP 8+ unless parse_str runs without the legacy behaviour. Easiest path: use urldecode after splitting on &.
function parseInitData(string $raw): array
{
$pairs = [];
foreach (explode('&', $raw) as $pair) {
if ($pair === '') {
continue;
}
$kv = explode('=', $pair, 2);
if (count($kv) !== 2) {
continue;
}
$pairs[urldecode($kv[0])] = urldecode($kv[1]);
}
return $pairs;
}
This gives you a flat array. hash is present at the top level alongside fields such as auth_date, query_id, user, start_param, and chat_type. Nested fields like user arrive as JSON; we leave them encoded until we are ready to decode, because the HMAC is computed over the raw values.
Building data_check_string
Telegram's spec is explicit: every remaining pair goes in key=value form, sorted by key, joined by \n. Keep the values exactly as they arrived after URL-decoding — no JSON re-encoding.
function buildCheckString(array $params): string
{
unset($params['hash']);
ksort($params, SORT_STRING);
$lines = [];
foreach ($params as $k => $v) {
$lines[] = $k . '=' . $v;
}
return implode("\n", $lines);
}
A subtle bug source is ksort flags. SORT_STRING matches the official examples. Locale-aware sorts (the default in some PHP builds under specific setlocale calls) will reorder letters and break the hash.
Deriving the secret key
The HMAC key is not the bot token itself. It is HMAC-SHA-256("WebAppData", bot_token), returned as raw bytes. Telegram's docs and the WebApp source both use this construction; if you skip the nested HMAC, every signature check fails.
function webAppSecretKey(string $botToken): string
{
return hash_hmac('sha256', "WebAppData", $botToken, true);
}
Pass true as the last argument to get the raw 32-byte digest, then feed that into hash_hmac again for the data check string.
Timing-safe comparison and auth_date TTL
Plain === between two hex strings leaks the prefix length through timing differences. Use hash_equals:
function verifyInitData(string $raw, string $botToken, int $maxAgeSeconds = 86400): array
{
$params = parseInitData($raw);
if (!isset($params['hash'], $params['auth_date'])) {
return ['ok' => false, 'reason' => 'missing_fields'];
}
$checkString = buildCheckString($params);
$secretKey = webAppSecretKey($botToken);
$computed = hash_hmac('sha256', $checkString, $secretKey);
if (!hash_equals($params['hash'], $computed)) {
return ['ok' => false, 'reason' => 'bad_signature'];
}
$authDate = (int) $params['auth_date'];
if ($authDate <= 0 || $authDate > time() + 60) {
return ['ok' => false, 'reason' => 'auth_date_in_future'];
}
if (time() - $authDate > $maxAgeSeconds) {
return ['ok' => false, 'reason' => 'auth_date_expired'];
}
$user = isset($params['user']) ? json_decode($params['user'], true) : null;
return [
'ok' => true,
'user_id' => is_array($user) && isset($user['id']) ? (int) $user['id'] : null,
'user' => $user,
'params' => $params,
];
}
A few deliberate choices:
- I add a small +60 second skew to the upper bound. Clock drift between the Telegram client and your server is small but real; rejecting a freshly minted
initDatabecause it is "1.2 seconds in the future" is the kind of bug that wastes an afternoon. - The default TTL is 24 hours. Shorter is safer (15 minutes is a common production value), longer is friendlier for offline SPA sessions. Pick what your threat model tolerates.
-
json_decodeon theuserfield is done after signature verification. If the JSON is malformed you still know the request was authentic; you just cannot trust the nested fields.
Wiring it into a controller
The Mini App client posts initData either raw (in the standard WebApp SDK) or as a form field. Treat the raw value as the source of truth. Read the bot token from configuration, never from request input.
$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
http_response_code(500);
exit('bot token not configured');
}
$raw = $_POST['initData'] ?? '';
if ($raw === '') {
http_response_code(400);
exit('initData missing');
}
$result = verifyInitData($raw, $token, maxAgeSeconds: 900);
if (!$result['ok']) {
http_response_code(401);
exit('invalid: ' . $result['reason']);
}
// result['user_id'] is now safe to bind to a session or DB row
Two hardening notes that are easy to forget:
-
Cache nothing based on
hashalone. A signedinitDataproves the client talked to your bot, but it does not authorise a specific user across requests. Bind to(bot_id, user_id)server-side. -
Do not log full
initData. It contains the user'sid,username, and sometimesstart_paramvalues that act as one-time tokens. Redact before logging.
Production notes (optional)
These are things I would add before this code faces real traffic; they are not strictly required for the validation step itself.
-
Rate-limit the endpoint. An attacker who cannot forge a signature can still spam your endpoint with garbage. Token-bucket by IP and by
tg_idonce you have one. -
Validate the nested user shape. Even after a valid signature, assert that
user.idis a positive integer and thatuser.is_botis false if you only serve humans. -
Use a stable clock source.
time()is fine for the TTL check, but if you fan out validation across multiple PHP-FPM workers behind a load balancer with skewed NTP, consider readingauth_dateagainst the same monotonic source you use everywhere else. -
Watch for
can_send_afterandchat_instance. If your Mini App is launched from an inline button inside a chat, those fields appear ininitData. Your check string still works — they are just extra keys that get sorted and hashed like any other — but you may want to assert their presence.
If you want a reference implementation that also exposes a typed DTO and integrates with the Login Widget hash, BotCreator ships Telegram bots and Mini Apps end-to-end. The Telegram Bot API reference is a good companion while you build:
Top comments (0)