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