При создании 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.
Процесс проверки подлинности состоит из четырех обязательных шагов:
- Извлечение параметра
hashиз общего массива и его удаление из проверяемого списка. - Сортировка оставшихся пар «ключ-значение» по алфавиту и их объединение через символ перевода строки (
\n) в так называемыйdata_check_string. - Генерация промежуточного секретного ключа HMAC-SHA256, где ключом выступает константа
WebAppData, а данными — токен вашего Telegram-бота. - Вычисление итогового 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.