Переклад цього гайду зʼявиться незабаром. Поки що текст доступний російською — перемкніться на RU або читайте нижче. Відкрити російську версію

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, его нужно декодировать отдельно.

Алгоритм проверки (пошагово)

  1. Парсим строку parse_str(), удаляем параметр hash.
  2. Сортируем оставшиеся пары по ключу (лексикографически, ksort()).
  3. Склеиваем в строку key=value\n (перевод строки между парами, без завершающего \n).
  4. Вычисляем secret_key = hash_hmac('sha256', 'WebAppData', $botToken, true) — важно: true возвращает raw binary.
  5. Вычисляем calculated_hash = hash_hmac('sha256', $dataString, $secretKey) — уже в hex.
  6. Сравниваем через hash_equals() (защита от timing-атак).
  7. Проверяем 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 или сессию.

← Что такое Mini App | Дальше: React и Vue →