Mini Apps

Validating initData

When a Mini App opens, Telegram passes initData — a string containing user, chat, and launch parameters. This string is signed by the bot, and your backend must validate the signature before trusting the data. In chapter Первый запрос: getMe, we created the telegramApi() function — it is not needed directly here, but the principle of working with HMAC and verifying the response is the same.

Structure of initData

The string arrives as a URL-encoded query string. Here is an example of what your PHP script sees in $_POST['initData'] or the JSON request body:

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

The hash field is an HMAC-SHA256 of the remaining parameters, sorted by key. The secret key is derived from the bot token via HMAC_SHA256("WebAppData", bot_token). The user parameter is a URL-encoded JSON, which needs to be decoded separately.

Verification Algorithm (Step-by-Step)

  1. Parse the parse_str() string, remove the hash parameter.
  2. Sort the remaining pairs by key (lexicographically, ksort()).
  3. Concatenate them into a string key=value\n (newline character between pairs, without a trailing ).
  4. Calculate secret_key = hash_hmac('sha256', 'WebAppData', $botToken, true) — important: true returns raw binary.
  5. Calculate calculated_hash = hash_hmac('sha256', $dataString, $secretKey) — already in hex.
  6. Compare using hash_equals() (protection against timing attacks).
  7. Check auth_date — not older than 24 hours (or your TTL).

PHP Implementation: Ready-to-Use Function

A complete working snippet of the verifyInitData.php file. We get the token from the environment, as in 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);

How to View the Result in the Terminal

Run the script via the built-in server and test it with 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"}'

Response for an invalid signature:

{
"ok": false,
"error": "Invalid initData signature or expired"
}

With valid data (the hash must be generated correctly — see the test below):

{
"ok": true,
"user": {
"id": 123,
"first_name": "Test",
"last_name": "",
"username": "",
"language_code": "ru"
},
"auth_date": 1700000000,
"query_id": "abc"
}

In your code, use var_dump($userData) or print_r($userData) before json_encode to see the structure in the server logs. For production, write to Monolog/log file.

Generating Valid initData for Testing on Your Own

To avoid relying on the frontend during API development, generate the correct hash in the same script:

<?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";

Run: php test_generate_initdata.php — you will get a ready-to-use string with the correct hash for testing.

Typical Mistakes and How to Catch Them

  • Not sorting parameters — ksort() is mandatory, the order is critical.
  • Using a string token instead of a binary key — hash_hmac(..., true) for secret_key returns raw binary; without true, it will result in hex and the verification will fail.
  • Not checking auth_date — leaves the door open for replay attacks. Set a TTL of 86400 (one day) or less.
  • Trusting user without parsing JSON — in initData, this is a URL-encoded JSON string; always use json_decode(..., true) and check json_last_error().
  • Comparing hash using === — use hash_equals() to prevent timing attacks.
  • Skipping query_id — it is unique for each Mini App launch and is useful for idempotency.

Passing from the Frontend (Reminder)

In the Mini App (JS), you get initData via Telegram.WebApp.initData and send it to your API. Example for 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);
}
});

The backend verifies it, creates/updates the user in the database (see БД и состояния (FSM)), and issues a JWT or session.

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