Mini Apps
Перевірка initData
Когда Mini App открывается, Telegram передаёт initData — строку с данными пользователя, чата и параметрами запуска. Эта строка подписана ботом, и ваш бэкенд должен проверить подпись, прежде чем доверять данным. В главе Первый запрос: getMe мы создали функцию telegramApi() — здесь она не нужна напрямую, но принцип работы с HMAC и проверкой ответа тот же.
Структура initData
Строка приходит как URL-encoded query string. Пример того, что видит ваш PHP-скрипт в $_POST['initData'] или теле JSON-запроса:
query_id=abc123&user=%7B%22id%22%3A123456%2C%22first_name%22%3A%22Ivan%22%2C%22last_name%22%3A%22Petrov%22%2C%22username%22%3A%22ivan_p%22%2C%22language_code%22%3A%22ru%22%7D&auth_date=1700000000&hash=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
Поле hash — это HMAC-SHA256 от остальных параметров, отсортированных по ключу. Секретный ключ формируется из токена бота через HMAC_SHA256("WebAppData", bot_token). Параметр user — это URL-encoded JSON, его нужно декодировать отдельно.
Алгоритм проверки (пошагово)
- Парсим строку
parse_str(), удаляем параметрhash. - Сортируем оставшиеся пары по ключу (лексикографически,
ksort()). - Склеиваем в строку
key=value\n(перевод строки между парами, без завершающего \n). - Вычисляем
secret_key = hash_hmac('sha256', 'WebAppData', $botToken, true)— важно:trueвозвращает raw binary. - Вычисляем
calculated_hash = hash_hmac('sha256', $dataString, $secretKey)— уже в hex. - Сравниваем через
hash_equals()(защита от timing-атак). - Проверяем
auth_date— не старше 24 часов (или вашего TTL).
PHP-реализация: готовая функция
Полный рабочий фрагмент файла verifyInitData.php. Токен берём из окружения, как в BotFather и токен.
<?php
function verifyInitData(string $initData, string $botToken, int $ttl = 86400): ?array
{
// 1. Парсим query string в массив
parse_str($initData, $params);
if (!isset($params['hash'])) {
error_log('initData: missing hash parameter');
return null;
}
$receivedHash = $params['hash'];
unset($params['hash']);
// 2. Сортируем по ключу
ksort($params);
// 3. Собираем data-string: key=value\n
$dataString = '';
foreach ($params as $key => $value) {
$dataString .= $key . '=' . $value . "\n";
}
$dataString = rtrim($dataString, "\n"); // убираем последний \n
// 4. Секретный ключ: HMAC-SHA256("WebAppData", bot_token) в raw binary
$secretKey = hash_hmac('sha256', 'WebAppData', $botToken, true);
// 5. Вычисляем ожидаемый hash
$calculatedHash = hash_hmac('sha256', $dataString, $secretKey);
// 6. Сравниваем безопасно
if (!hash_equals($calculatedHash, $receivedHash)) {
error_log('initData: hash mismatch');
return null;
}
// 7. Проверяем свежесть auth_date
if (isset($params['auth_date'])) {
$authDate = (int)$params['auth_date'];
if (time() - $authDate > $ttl) {
error_log('initData: expired, auth_date=' . $authDate);
return null;
}
}
// 8. Декодируем user (это URL-encoded JSON)
if (isset($params['user'])) {
$params['user'] = json_decode($params['user'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('initData: user JSON decode error: ' . json_last_error_msg());
$params['user'] = null;
}
}
// query_id тоже может пригодиться
if (isset($params['query_id'])) {
$params['query_id'] = (string)$params['query_id'];
}
return $params;
}
// --- Использование в эндпоинте ---
$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '';
if ($botToken === '') {
error_log('TELEGRAM_BOT_TOKEN not set');
http_response_code(500);
exit('Server configuration error');
}
// Принимаем и JSON, и form-data
$input = file_get_contents('php://input');
$payload = json_decode($input, true);
$initData = $payload['initData'] ?? ($_POST['initData'] ?? '');
if ($initData === '') {
http_response_code(400);
exit(json_encode(['ok' => false, 'error' => 'initData required']));
}
$userData = verifyInitData($initData, $botToken);
if ($userData === null) {
http_response_code(401);
exit(json_encode(['ok' => false, 'error' => 'Invalid initData signature or expired']));
}
// Успех: возвращаем данные фронтенду или выдаём JWT
$user = $userData['user'] ?? [];
$response = [
'ok' => true,
'user' => [
'id' => $user['id'] ?? null,
'first_name' => $user['first_name'] ?? '',
'last_name' => $user['last_name'] ?? '',
'username' => $user['username'] ?? '',
'language_code' => $user['language_code'] ?? 'ru',
],
'auth_date' => $userData['auth_date'] ?? null,
'query_id' => $userData['query_id'] ?? null,
];
header('Content-Type: application/json');
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
Как посмотреть результат в терминале
Запустите скрипт через встроенный сервер и тестируйте curl-ом:
php -S localhost:8000 verifyInitData.php
curl -X POST http://localhost:8000/verifyInitData.php \
-H "Content-Type: application/json" \
-d '{"initData":"query_id=abc&user=%7B%5C%22id%5C%22%3A123%5C%22%2C%5C%22first_name%5C%22%3A%5C%22Test%5C%22%7D&auth_date=1700000000&hash=invalid"}'
Ответ при неверной подписи:
{
"ok": false,
"error": "Invalid initData signature or expired"
}
При валидных данных (hash нужно сгенерировать правильно — см. тест ниже):
{
"ok": true,
"user": {
"id": 123,
"first_name": "Test",
"last_name": "",
"username": "",
"language_code": "ru"
},
"auth_date": 1700000000,
"query_id": "abc"
}
В коде используйте var_dump($userData) или print_r($userData) перед json_encode, чтобы видеть структуру в логах сервера. Для продакшена — пишите в Monolog/лог-файл.
Самостоятельная генерация валидного initData для тестов
Чтобы не зависеть от фронтенда при разработке API, сгенерируйте правильный hash в том же скрипте:
<?php
// test_generate_initdata.php — запустите один раз, скопируйте hash
$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11';
$params = [
'query_id' => 'test_query_1',
'user' => json_encode([
'id' => 123456,
'first_name' => 'Ivan',
'last_name' => 'Petrov',
'username' => 'ivan_p',
'language_code' => 'ru',
], JSON_UNESCAPED_UNICODE),
'auth_date' => (string)time(),
];
ksort($params);
$dataString = '';
foreach ($params as $k => $v) {
$dataString .= $k . '=' . $v . "\n";
}
$dataString = rtrim($dataString, "\n");
$secretKey = hash_hmac('sha256', 'WebAppData', $botToken, true);
$hash = hash_hmac('sha256', $dataString, $secretKey);
$initData = http_build_query($params) . '&hash=' . $hash;
echo "initData: " . $initData . "\n";
echo "hash: " . $hash . "\n";
Запустите: php test_generate_initdata.php — получите готовую строку с верным hash для тестов.
Типичные ошибки и как их ловить
- Не сортируют параметры —
ksort()обязателен, порядок критичен. - Используют строковый токен вместо бинарного ключа —
hash_hmac(..., true)дляsecret_keyвозвращает raw binary, безtrueполучится hex и проверка упадёт. - Не проверяют auth_date — открывает реплей-атаки. Ставьте TTL 86400 (сутки) или меньше.
- Доверяют user без парсинга JSON — в initData это URL-encoded JSON-строка, всегда делайте
json_decode(..., true)и проверяйтеjson_last_error(). - Сравнивают hash через === — используйте
hash_equals(), иначе timing-атака. - Пропускают query_id — он уникален для каждого открытия Mini App, полезен для идемпотентности.
Передача с фронтенда (напоминание)
В Mini App (JS) получаете initData через Telegram.WebApp.initData и отправляете на свой API. Пример для fetch:
// Внутри Mini App
const initData = Telegram.WebApp.initData;
fetch('https://api.example.com/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData })
})
.then(r => r.json())
.then(data => {
if (data.ok) {
// сохраняем токен/сессию, показываем основной UI
console.log('Authorized as', data.user.first_name);
} else {
console.error('Auth failed:', data.error);
}
});
Бэкенд проверяет, создаёт/обновляет пользователя в БД (см. БД и состояния (FSM)), выдаёт JWT или сессию.