When creating Telegram Mini Apps (TMAs), a key step in secure authorization is processing the initData string. The frontend application running inside the Telegram WebView receives authorization data from the client. However, you must absolutely not trust the window.Telegram.WebApp.initDataUnsafe object: the client can intercept the request, spoof the user.id, or generate any data using developer tools.
To protect the backend, Telegram transmits the raw initData string signed with a cryptographic hash. In this article, we will examine in detail how to algorithmically validate this string correctly on the backend using PHP, calculate an intermediate secret key, protect against timing attacks, and prevent time-based replay attacks.
How Telegram initData Signature Works
The initData string is a set of URL-encoded parameters separated by an ampersand (&). They include the user ID, session parameters, the signature creation time auth_date, and the resulting cryptographic hash in the hash parameter.
The authentication process consists of four mandatory steps:
- Extracting the
hashparameter from the overall array and removing it from the list to be verified. - Sorting the remaining key-value pairs alphabetically and joining them with a newline character (
\n) into the so-calleddata_check_string. - Generating an intermediate HMAC-SHA256 secret key, where the key is the constant
WebAppDataand the data is your Telegram-бот token. - Calculating the final HMAC-SHA256 of
data_check_stringusing the derived secret key and comparing it with the providedhash.
Step 1. initData Validator Class in Pure PHP
Let's implement an isolated TelegramInitDataValidator class. We avoid third-party framework dependencies so that the code easily integrates into Yii2 or Laravel, as well as pure PSR-7 applications.
<?php
namespace App\Security;
class SecurityException extends \Exception {}
class TelegramInitDataValidator
{
private string $botToken;
private int $maxAgeSeconds;
/**
* @param string $botToken Токен бота из getenv() или конфига
* @param int $maxAgeSeconds Максимальный срок жизни подписи (по умолчанию 24 часа)
*/
public function __construct(string $botToken, int $maxAgeSeconds = 86400)
{
$this->botToken = $botToken;
$this->maxAgeSeconds = $maxAgeSeconds;
}
/**
* Валидирует сырую строку initData и возвращает разобранный массив данных
*
* @param string $initDataRaw
* @return array
* @throws SecurityException|\InvalidArgumentException
*/
public function validate(string $initDataRaw): array
{
if (empty($initDataRaw)) {
throw new \InvalidArgumentException('Строка initData пуста');
}
parse_str($initDataRaw, $data);
if (!isset($data['hash']) || !is_string($data['hash'])) {
throw new \InvalidArgumentException('В initData отсутствует параметр hash');
}
$receivedHash = $data['hash'];
unset($data['hash']);
// 1. Сортировка ключей в алфавитном порядке
ksort($data);
// 2. Формирование data_check_string вида "key=value\nkey2=value2"
$dataCheckArr = [];
foreach ($data as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);
// 3. Вычисление secret_key = HMAC-SHA256("WebAppData", bot_token)
// Важно: в hash_hmac третий аргумент — это ключ ('WebAppData'), а второй — данные (bot_token)
$secretKey = hash_hmac('sha256', $this->botToken, 'WebAppData', true);
// 4. Генерация контрольного хэша в hex-формате
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
// 5. Timing-safe сравнение строк для защиты от атак по времени
if (!hash_equals($calculatedHash, $receivedHash)) {
throw new SecurityException('Недействительная подпись initData (hash mismatch)');
}
// 6. Валидация срока жизни auth_date
$authDate = (int)($data['auth_date'] ?? 0);
$currentTime = time();
if ($authDate <= 0 || ($currentTime - $authDate) > $this->maxAgeSeconds) {
throw new SecurityException('Срок действия подписи initData истек');
}
// Защита от расхождения системных часов (clock skew)
if ($authDate > ($currentTime + 300)) {
throw new SecurityException('Параметр auth_date указывет на время в будущем');
}
// Декодирование вложенного JSON-объекта user при наличии
if (isset($data['user']) && is_string($data['user'])) {
$decodedUser = json_decode($data['user'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$data['user_parsed'] = $decodedUser;
}
}
return $data;
}
}
Protection Against Timing Attacks and Replay Attacks
Two security aspects are critically important in the code above:
1. Using hash_equals Instead of the === Operator
Regular string comparison using === terminates immediately on the first mismatched character. An attacker can measure the server response time with high precision and guess the correct hash character by character down to the millisecond. The hash_equals() function performs comparisons in constant time, eliminating timing attack side-channel leaks.
2. Checking the auth_date Parameter
Even if the криптографическая подпись is valid, an attacker could intercept a legitimate initData string from another user's request headers and resend it to your server (Replay Attack). By limiting the lifespan of auth_date (for example, to 86,400 seconds or less), you prevent the indefinite use of compromised tokens.
Step 2. Integration with HTTP API and Telegram API Request via cURL
Let's examine the authorization processing controller. It is recommended to pass initData via a custom header (for example, X-Telegram-Init-Data) or the Authorization: tma <initData> scheme so that data does not end up in web server access logs via GET parameters.
<?php
require_once __DIR__ . '/TelegramInitDataValidator.php';
use App\Security\TelegramInitDataValidator;
use App\Security\SecurityException;
header('Content-Type: application/json; charset=utf-8');
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Ошибка конфигурации сервера: отсутствует токен']);
exit;
}
// Извлекаем initData из HTTP-заголовков
$headers = getallheaders();
$initDataRaw = $headers['X-Telegram-Init-Data'] ?? $headers['x-telegram-init-data'] ?? '';
if (empty($initDataRaw)) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Заголовок X-Telegram-Init-Data не передан']);
exit;
}
$validator = new TelegramInitDataValidator($botToken, 86400);
try {
// Валидируем initData
$validatedData = $validator->validate($initDataRaw);
$user = $validatedData['user_parsed'] ?? null;
if (!$user || !isset($user['id'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'В initData отсутствует объект пользователя']);
exit;
}
$telegramUserId = (int)$user['id'];
// Пример доп. проверки через Telegram Bot API: проверяем статус подписки пользователя в канале
$ch = curl_init('https://api.telegram.org/bot' . $botToken . '/getChatMember');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_POSTFIELDS => http_build_query([
'chat_id' => '@your_channel_username',
'user_id' => $telegramUserId,
]),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($response === false) {
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'cURL error: ' . $curlErr]);
exit;
}
$apiResult = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || $httpCode !== 200 || !($apiResult['ok'] ?? false)) {
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'Telegram API вернул ошибку']);
exit;
}
$memberStatus = $apiResult['result']['status'] ?? 'left';
// Авторизация успешна, формируем ответ приложения
echo json_encode([
'ok' => true,
'user' => [
'id' => $user['id'],
'first_name' => $user['first_name'] ?? '',
'username' => $user['username'] ?? '',
],
'is_subscribed' => in_array($memberStatus, ['creator', 'administrator', 'member'], true)
]);
} catch (SecurityException $e) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Ошибка безопасности: ' . $e->getMessage()]);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Некорректный запрос: ' . $e->getMessage()]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Внутренняя ошибка сервера']);
}
Common Mistakes When Validating initData
- Using urlencode when constructing data_check_string: Parameter values in
parse_str()are already automatically decoded. There is no need to re-encode them withurlencode()before concatenating strings. - Mixing up argument order in hash_hmac: In the first step, we compute
hash_hmac('sha256', $botToken, 'WebAppData', true). Here, the string"WebAppData"is the key, and the bot token is the message being checked. If you swap them, the hash won't match. - Ignoring the raw_output flag in the secret key: The secret key generation function must return raw binary data (4th parameter set to
true), whereas the final call tohash_hmacfordata_check_stringshould return a hex string (4th parameter set tofalseby default). - Saving sessions without linking to Telegram ID: After validating
initData, be sure to create your own session (JWT or Cookie) on the backend, without unnecessarily passing the originalinitDatain every request.
If you need development of a reliable Telegram Mini App with ready-made architecture and data validation, contact the specialists at BotCreator.