Валідація Telegram WebApp initData на PHP: crypt-key, timing-safe хеш та автентифікація

Під час створення 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

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