Валидация Telegram WebApp initData на PHP: crypt-key, timing-safe hash и аутентификация

При создании Telegram Mini Apps (TMA) ключевым этапом безопасной авторизации является обработка строки initData. Фронтенд-приложение, работающее внутри Telegram WebView, получает от клиента данные авторизации. Однако доверять объекту window.Telegram.WebApp.initDataUnsafe категорически нельзя: клиент может перехватить запрос, подменить user.id или сгенерировать любые данные в инструментах разработчика.

Для защиты бэкенда Telegram передает сырую строку initData, подписанную криптографическим хэшем. В этой статье мы подробно разберем, как алгоритмически правильно валидировать эту строку на бэкенде с помощью PHP, вычислить промежуточный секретный ключ, защититься от timing-атак и предотвратить Replay-атаки по времени.

Как устроена подпись Telegram initData

Строка initData представляет собой набор URL-encoded параметров, разделенных амперсандом (&). Среди них передаются идентификатор пользователя, параметры сессии, время создания подписи auth_date и итоговый криптографический хэш в параметре hash.

Процесс проверки подлинности состоит из четырех обязательных шагов:

  1. Извлечение параметра hash из общего массива и его удаление из проверяемого списка.
  2. Сортировка оставшихся пар «ключ-значение» по алфавиту и их объединение через символ перевода строки (\n) в так называемый data_check_string.
  3. Генерация промежуточного секретного ключа HMAC-SHA256, где ключом выступает константа WebAppData, а данными — токен вашего Telegram-бота.
  4. Вычисление итогового HMAC-SHA256 от data_check_string с использованием полученного секретного ключа и его сравнение с переданным hash.

Шаг 1. Класс валидатора initData на чистом PHP

Реализуем изолированный класс TelegramInitDataValidator. Мы не используем сторонние фреймворковые зависимости, чтобы код легко интегрировался как в Yii2 или Laravel, так и в чистые PSR-7 приложения.

<?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;
}
}

Защита от timing-атак и Replay-атак

В приведенном коде критически важны два момента безопасности:

1. Использование hash_equals вместо оператора ===

Обычное сравнение строк через === завершает работу сразу при первом несовпавшем символе. Атакующий может измерять время отклика сервера с высокой точностью и помиллисекундно подбирать правильный хэш символ за символом. Функция hash_equals() выполняет сравнение за константное время, нивелируя утечки через сторонние каналы времени (timing attacks).

2. Проверка параметра auth_date

Даже если криптографическая подпись корректна, злоумышленник может перехватить легитимную строку initData из заголовков чужого запроса и повторно отправлять ее на ваш сервер (Replay Attack). Ограничивая время жизни auth_date (например, 86400 секундами или меньше), вы предотвращаете бессрочное использование скопрометированных токенов.

Шаг 2. Интеграция с HTTP API и запрос к Telegram API через cURL

Рассмотрим контроллер обработки авторизации. Передавать initData рекомендуется через кастомный заголовок (например, X-Telegram-Init-Data) или схему Authorization: tma <initData>, чтобы данные не попадали в access-логи веб-сервера через GET-параметры.

<?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' => 'Внутренняя ошибка сервера']);
}

Частые ошибки при проверке initData

  • Использование urlencode при сборке data_check_string: Значения параметров в parse_str() уже автоматически декодируются. Не нужно повторно кодировать их в urlencode() перед склейкой строк.
  • Перепутан порядок аргументов в hash_hmac: Первым шагом мы вычисляем hash_hmac('sha256', $botToken, 'WebAppData', true). Здесь строка "WebAppData" является ключом, а токен бота — проверяемым сообщением. Если поменять их местами, хэш не совпадет.
  • Игнорирование флага raw_output в секретном ключе: Функция генерации секретного ключа должна возвращать сырые бинарные данные (4-й параметр true), а итоговый вызов hash_hmac от data_check_string должен возвращать hex-строку (4-й параметр false по умолчанию).
  • Сохранение сессий без привязки к Telegram ID: После валидации initData обязательно создавайте собственную сессию (JWT или Cookie) на бэкенде, не передавая исходный initData в каждом запросе без необходимости.

Если вам требуется разработка надежного Telegram Mini App с готовой архитектурой и валидацией данных, обратитесь к специалистам BotCreator.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.