При передаче данных из Telegram Mini App (TWA) на бэкенд критически важно убедиться, что запрос действительно сформирован Telegram, а не подделан злоумышленником. Телеграм предоставляет объект initData в виде query-строки, содержащей данные пользователя, время авторизации и криптографический хеш. Если обработать эти данные без проверки подписи, злоумышленник сможет отправить произвольный user.id и получить доступ к чужим аккаунтам или балансу.
Структура initData и алгоритм проверки подлинности
Параметр initData представляет собой URL-encoded строку формата query_id=...&user=...&auth_date=...&hash=.... Для верификации подписи на бэкенде необходимо выполнить последовательность шагов:
- Распарсить query-строку в ассоциативный массив параметров.
- Извлечь параметр
hashи удалить его из массива проверяемых данных. - Отсортировать оставшиеся пары «ключ-значение» по алфавиту по именам ключей.
- Сформировать строку
data_check_string, где пары объединены через символ перевода строки\nв форматеkey=value. - Сгенерировать секретный ключ (
secret_key) путем хеширования токена бота с помощью HMAC-SHA-256, используя константную строкуWebAppDataв качестве ключа. - Вычислить итоговый HMAC-SHA-256 хеш от
data_check_stringс использованиемsecret_key. - Сравнить полученный хеш с оригинальным
hashиз запроса в защищенном от атак по времени режиме. - Проверить актуальность метки времени
auth_date.
Создание HMAC-SHA-256 секретного ключа и подпись данных
Особенность криптографической схемы Telegram Mini Apps заключается в двухэтапном хешировании. Токен бота нельзя использовать напрямую как ключ для подписи данных. Сначала создается промежуточный бинарный ключ, где контрольной строкой выступает ASCII-литерал WebAppData.
В PHP функция hash_hmac с аргументом $as_binary = true возвращает сырые бинарные данные, которые затем передаются вторым аргументом при расчете финального HMAC. Итоговый хеш выдается в виде hex-строки в нижнем регистре.
<?php
declare(strict_types=1);
/**
* Валидация подписи initData из Telegram Mini App.
*
* @param string $initData Сырая query-строка от Telegram WebApp
* @param string $botToken Токен бота, полученный от @BotFather
* @param int $maxAgeSec Максимальное время жизни подписи в секундах
* @return array{is_valid: bool, data: array<string, mixed>, error: ?string}
*/
function validateTelegramInitData(string $initData, string $botToken, int $maxAgeSec = 86400): array
{
if (trim($initData) === '') {
return ['is_valid' => false, 'data' => [], 'error' => 'InitData is empty'];
}
// Парсинг query-строки без потери структуры JSON внутри параметров
$params = [];
parse_str($initData, $params);
if (!isset($params['hash']) || !is_string($params['hash'])) {
return ['is_valid' => false, 'data' => [], 'error' => 'Hash missing in payload'];
}
$receivedHash = $params['hash'];
unset($params['hash']);
// Сортировка параметров по ключам в алфавитном порядке
ksort($params);
$dataCheckArr = [];
foreach ($params as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);
// Этап 1: Вычисление secret_key = HMAC_SHA256("WebAppData", bot_token) в бинарном виде
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
// Этап 2: Вычисление hash = HMAC_SHA256(secret_key, data_check_string) в hex-формате
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
// Timing-safe сравнение строк для исключения атаки по времени
if (!hash_equals($calculatedHash, $receivedHash)) {
return ['is_valid' => false, 'data' => [], 'error' => 'Invalid hash signature'];
}
// Проверка наличия и возраста auth_date
if (!isset($params['auth_date']) || !is_numeric($params['auth_date'])) {
return ['is_valid' => false, 'data' => [], 'error' => 'Auth date missing'];
}
$authDate = (int)$params['auth_date'];
if ((time() - $authDate) > $maxAgeSec) {
return ['is_valid' => false, 'data' => [], 'error' => 'InitData standard TTL expired'];
}
// Декодируем объект user из JSON, если он есть
if (isset($params['user']) && is_string($params['user'])) {
$decodedUser = json_decode($params['user'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$params['user'] = $decodedUser;
}
}
return ['is_valid' => true, 'data' => $params, 'error' => null];
}
Защита от Timing Attacks с помощью hash_equals()
Обычное операторное сравнение строк (===) в PHP завершает работу на первом же несоответствующем байте. Это позволяет злоумышленнику замерить микросекундные задержки ответа сервера и побайтово подобрать валидный хеш подписи (атака по времени или Timing Attack).
Для исключения этой уязвимости необходимо использовать функцию hash_equals(). Она выполняет сравнение за константное время, независимо от того, в каком именно байте найдены различия, что делает невозможным тайминг-анализ на стороне клиента.
Контроль срока жизни auth_date и предотвращение Replay-атак
Успешная проверка подписи подписи HMAC подтверждает, что данные не были изменены. Однако подлинная строка initData может быть перехвачена и повторно отправлена позже (Replay Attack). Чтобы не допустить повторного использования устаревших сессий, обязательной является проверка поля auth_date.
Параметр auth_date содержит Unix timestamp момента создания подписи на клиенте. Стандартное окно валидности составляет 86400 секунд (24 часа). В критических операциях, таких как финансовые транзакции или списание бонусов, TTL рекомендуется уменьшать до 300–600 секунд.
Обработка запроса на бэкенде: контроллер авторизации
Ниже приведен практический пример точки входа API, которая принимает initData от клиента, валидирует подпись и возвращает авторизационный ответ или ошибку доступа.
<?php
declare(strict_types=1);
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' => 'Server environment error']);
exit;
}
// Извлечение JSON-тела запроса
$inputRaw = file_get_contents('php://input');
$payload = json_decode($inputRaw ?: '', true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid JSON payload']);
exit;
}
$initData = $payload['init_data'] ?? null;
if (!is_string($initData) || $initData === '') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Parameter init_data is required']);
exit;
}
// Валидация initData с TTL в 24 часа
$validationResult = validateTelegramInitData($initData, $botToken, 86400);
if (!$validationResult['is_valid']) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'Authentication failed: ' . $validationResult['error']
]);
exit;
}
$userData = $validationResult['data']['user'] ?? null;
if (!$userData || !isset($userData['id'])) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => 'User context missing in initData']);
exit;
}
// Успешный ответ: пользователь верифицирован
echo json_encode([
'ok' => true,
'result' => [
'telegram_id' => $userData['id'],
'first_name' => $userData['first_name'] ?? '',
'username' => $userData['username'] ?? null,
'auth_date' => $validationResult['data']['auth_date']
]
]);
Частые ошибки при разборе initData
- Двойное декодирование URL: Если фреймворк или веб-сервер уже декодировал query-строку, повторный вызвав
urldecode()разрушит структуру JSON-полей (например, строки внутриuser). Используйте сырую строку, полученную от клиента. - Нарушение порядка сортировки: Сортировка
ksort()должна выполняться по ключам после двойного разделения пар. Значения элементов при этом не должны модифицироваться до проверки подписи. - Игнорирование бинарного флага HMAC: При вычислении промежуточного
secret_keyвыводhash_hmacобязан быть бинарным (аргумент$as_binary = true), иначе вы получите hex-строку и итоговый хеш не совпадет. - Разссинхронизация времени сервера: Если системные часы сервера отстают или спешат, проверка
auth_dateможет ошибочно отклонять валидные запросы. Используйте NTP-синхронизацию на сервере приложений.
Если вам требуется профессиональная интеграция Telegram Mini Apps с готовой архитектурой бэкенда, обратитесь к специалистам BotCreator.