Перевірка Telegram WebApp initData на PHP: парсинг, HMAC-SHA-256 та timing-safe валідація

Під час надсилання даних із Telegram Mini App на ваш бекенд єдиним джерелом правди є рядок initData. Його формує клієнтський додаток Telegram і підписує закритим ключом бота. Якщо валідувати дані некоректно або довіряти параметрам initDataUnsafe із клієнтського JavaScript, зловмисник зможе підробити user.id, обійти авторизацію або виконати дії від імені будь-якого користувача.

У цьому матеріалі ми розберемо безпечний алгоритм перевірки initData на PHP, напишемо ізольовану функцію валідації та розглянемо надсилання зворотного виклику в Telegram API з дотриманням стандартів обробки помилок.

Анатомія рядка initData та алгоритм перевірки

Рядок initData є набором пар key=value, розділених амперсандом (&), аналогічно стандартному URL query string. Серед параметрів передаються query_id, user (JSON-рядок із даними профілю), auth_date (Unix timestamp) і сам hash.

Процес перевірки автентичності складається з п'яти обов'язкових кроків:

  1. Вилучення підпису: з отриманого рядка відокремлюється параметр hash. Усі інші параметри залишаються для збирання перевірочного рядка.
  2. Сортування за алфавітом: решта пар key=value сортуються за ключем в алфавітному порядку (A-Z).
  3. Формування data_check_string: відсортовані пари об'єднуються через символ перенесення рядка ( ).
  4. Генерація HMAC-ключа: створюється байтовий HMAC-SHA-256 геш від рядка "WebAppData", де ключем виступає токен вашого бота.
  5. Порівняння гешів: обчислюється контрольний HMAC-SHA-256 від data_check_string із використанням згенерованого ключа. Результат порівнюється з отриманим hash через функції, стійкі до атак за часом (timing attacks).

Покрокова реалізація парсингу та валідації на чистому PHP

Під час обробки query string не можна використовувати базовий parse_str() без попередньої обробки, оскільки він декодує значення та може змінити структуру JSON усередині параметра user, що порушить підсумковий підпис. Надійніше розібрати рядок вручну або працювати з початковими urldecode-значеннями.

Нижче наведено готовий хелпер для перевірки автентичності initData з обмеженням за часом життя підпису (за замовчуванням — 24 години):

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

Перевірка auth_date та захист від Replay Attacks

Навіть якщо контрольний геш збігається, вразливість залишається, якщо підпис можна використовувати нескінченно. Зловмисник, який перехопив чужий initData, зможе надсилати його на ваш сервер із будь-якого пристрою.

Перевірка auth_date вирішує цю проблему лише частково, обмежуючи «вікно життя» пакета (наприклад, до 86400 секунд). Щоб виключити повторне використання справжніх даних усередині цього вікна (Replay Attack), необхідно логувати унікальні ідентифікатори на боці бекенду.

  • Зберігайте query_id або обчислений hash у Redis / DB із часом життя (TTL), що дорівнює допустимому вікну auth_date.
  • Якщо отриманий hash уже присутній у сховищі, відхиляйте запит із HTTP-кодом 403 Forbidden.

Прийом запиту від Mini App та робота з Telegram Bot API

Розглянемо практичний ендпоінт, який приймає initData в HTTP-заголовку, здійснює валідацію, вилучає ID користувача та надсилає йому сервісне сповіщення через метод sendMessage Bot API з використанням 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'] ?? '',
]);

Часті помилки під час реалізації перевірки

Навіть із правильним алгоритмом розробники припускаються критичних помилок, які призводять або до хибних спрацьовувань, або до вразливостей:

  • Забувають аргумент raw_output = true у HMAC: Виклик hash_hmac('sha256', $botToken, 'WebAppData') без четвертого параметра true повертає hex-рядок замість бінарного масиву байтів. Це ламає обчислення фінального гешу.
  • Порівняння через === замість hash_equals(): Оператор суворої рівності перериває порівняння на першому баті, що не збігся, що відкриває можливість атаки за часом (Timing Attack) для підбору підпису.
  • Мутація JSON-рядка user: Якщо розкодувати JSON із параметра user до обчислення data_check_string і зібрати його назад через json_encode(), форматування (пробіли, екранування слешів) зміниться. Підпис перестане збігатися.
  • Відсутність перевірки HTTP-коду cURL: Під час надсилання запитів до Telegram API необхідно завжди перевіряти HTTP-код відповіді та статус json_last_error(), щоб вчасно обробляти таймаути та мережеві збої.

Якщо вам потрібна комплексна розробка Telegram Mini App та інфраструктури для обробки заявок, фахівці BotCreator допоможуть спроєктувати архітектуру та захистити API.

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.