Telegram WebApp initData Verification in PHP: Parsing, HMAC-SHA-256, and Timing-Safe Validation

When sending data from Telegram Mini App to your backend, the only single source of truth is the initData string. It is generated by the Telegram client application and signed with the bot's private key. If the data is validated incorrectly or if you trust the initDataUnsafe parameters from client-side JavaScript, an attacker will be able to spoof the user.id, bypass authentication, or perform actions on behalf of any user.

In this article, we will break down a secure algorithm for verifying initData in PHP, write an isolated validation function, and cover sending a callback to the Telegram API in compliance with error handling standards.

Anatomy of the initData String and Verification Algorithm

The initData string is a set of key=value pairs separated by an ampersand (&), similar to a standard URL query string. The parameters passed include query_id, user (a JSON string with profile data), auth_date (Unix timestamp), and the hash itself.

The authentication process consists of five required steps:

  1. Signature Extraction: the hash parameter is separated from the received string. All remaining parameters are left to assemble the data check string.
  2. Alphabetical Sorting: the remaining key=value pairs are sorted by key in alphabetical order (A-Z).
  3. Constructing the data_check_string: the sorted pairs are joined together using a newline character ( ).
  4. HMAC Key Generation: a raw byte HMAC-SHA-256 hash of the string "WebAppData" is created, using your bot token as the secret key.
  5. Hash Comparison: a control HMAC-SHA-256 is calculated for the data_check_string using the generated key. The result is compared with the received hash using functions resistant to timing attacks.

Step-by-Step Implementation of Parsing and Validation in Pure PHP

When processing a query string, you cannot use basic parse_str() without preprocessing, as it decodes values and may modify the structure of the JSON inside the user parameter, breaking the final signature. It is more reliable to parse the string manually or work with raw URL-decoded values.

Below is a ready-to-use helper for authenticating initData with a signature lifetime limit (24 hours by default):

<?php

declare(strict_types=1);

/**
* Валидирует подпись initData от Telegram Mini App.
*
* @param string $initData Сырая строка window.Telegram.WebApp.initData
* @param string $botToken Токен бота из getenv() или конфига
* @param int $maxAge Максимально допустимый возраст подписи в секундах
* @return array|false Возвращает массив распарсенных данных или false при ошибке
*/
function validateTelegramInitData(string $initData, string $botToken, int $maxAge = 86400): array|false
{
if (empty($initData) || empty($botToken)) {
return false;
}

// 1. Разбираем query string с сохранением исходного кодирования
$rawData = explode('&', $initData);
$params = [];
$receivedHash = '';

foreach ($rawData as $pair) {
if (strpos($pair, '=') === false) {
continue;
}

[$key, $value] = explode('=', $pair, 2);
$decodedKey = urldecode($key);
$decodedValue = urldecode($value);

if ($decodedKey === 'hash') {
$receivedHash = $decodedValue;
} else {
$params[$decodedKey] = $decodedValue;
}
}

if (empty($receivedHash)) {
return false;
}

// 2. Сортируем параметры по алфавиту ключей
ksort($params);

// 3. Формируем data_check_string
$dataCheckArr = [];
foreach ($params as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);

// 4. Генерируем HMAC-SHA-256 секретный ключ
// Константа 'WebAppData' используется в качестве ключа для хэширования токена бота
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);

// 5. Вычисляем итоговый hash
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

// 6. Timing-safe сравнение хэшей
if (!hash_equals($calculatedHash, $receivedHash)) {
return false;
}

// 7. Проверка срока жизни auth_date
$authDate = isset($params['auth_date']) ? (int)$params['auth_date'] : 0;
if ($authDate === 0 || (time() - $authDate) > $maxAge) {
return false;
}

// Декодируем поле user, если оно присутствовало
if (isset($params['user'])) {
$params['user'] = json_decode($params['user'], true) ?? $params['user'];
}

return $params;
}

Validating auth_date and Protecting Against Replay Attacks

Even if the check hash matches, a vulnerability remains if the signature can be reused indefinitely. An attacker who intercepts someone else's initData can send it to your server from any device.

Checking auth_date only partially solves this problem by limiting the validity window of the packet (for example, to 86,400 seconds). To prevent the reuse of genuine data within this window (Replay Attack), you need to log unique identifiers on the backend side.

  • Store the query_id or computed hash in Redis / DB with a Time To Live (TTL) equal to the allowed auth_date window.
  • If the received hash is already present in the store, reject the request with HTTP code 403 Forbidden.

Receiving Requests from a Mini App and Working with the Telegram Bot API

Let's consider a practical endpoint that receives initData in an HTTP header, validates it, extracts the user ID, and sends a service notification using the Bot API's sendMessage method via cURL.

<?php

declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');

$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '';
if (empty($botToken)) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Bot token is not configured']);
exit;
}

// Извлекаем initData из заголовков или POST-тела
$headers = getallheaders();
$initData = $headers['X-Telegram-Init-Data'] ?? $_POST['init_data'] ?? '';

$validData = validateTelegramInitData($initData, $botToken, 86400);

if ($validData === false) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Invalid or expired initData']);
exit;
}

$user = $validData['user'] ?? null;
if (!is_array($user) || !isset($user['id'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'User payload missing']);
exit;
}

$telegramUserId = (int)$user['id'];

// Пример безопасной отправки сообщения пользователю через cURL
$telegramApiUrl = "https://api.telegram.org/bot{$botToken}/sendMessage";
$postFields = [
'chat_id' => $telegramUserId,
'text' => "Успешная авторизация в Mini App! Ваш ID: {$telegramUserId}",
'parse_mode' => 'HTML',
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $telegramApiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($postFields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);

if ($response === false || $httpCode !== 200) {
// В продакшене необходимо записать $curlError в лог
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'Failed to notify user via Telegram API']);
exit;
}

$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !($responseData['ok'] ?? false)) {
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'Telegram API returned error']);
exit;
}

// Успешный ответ клиенту
echo json_encode([
'ok' => true,
'user_id' => $telegramUserId,
'first_name' => $user['first_name'] ?? '',
]);

Common Mistakes When Implementing Verification

Even with the correct algorithm, developers make critical mistakes leading either to false positives or vulnerabilities:

  • Forgetting the raw_output = true argument in HMAC: Calling hash_hmac('sha256', $botToken, 'WebAppData') without the fourth parameter set to true returns a hex string instead of a raw binary byte array. This breaks the final hash calculation.
  • Comparing with === instead of hash_equals(): The strict equality operator stops comparing on the first mismatched byte, creating an opportunity for a Timing Attack to forge the signature.
  • Mutating the user JSON string: Decoding the JSON from the user parameter before calculating data_check_string and re-assembling it via json_encode() changes the formatting (spaces, escaped slashes). The signature will no longer match.
  • Missing HTTP response code check in cURL: When sending requests to the Telegram API, always verify the HTTP response code and json_last_error() status to handle timeouts and network failures in a timely manner.

If you need end-to-end development of a Telegram Mini App and ticket/lead processing infrastructure, the specialists at BotCreator can help design the architecture and secure your API.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.