Розробники Telegram Mini Apps часто припускаються фатальної помилки: довіряють об'єкту window.Telegram.WebApp.initDataUnsafe на стороні клієнта. Оскільки клієнтський застосунок виконується в контексті WebView (або браузера), будь-який користувач може легко відкрити DevTools, підмінити user.id на ідентифікатор адміністратора або будь-якого іншого клієнта та надіслати запит на бекенд.
У цій статті ми продемонструємо, як саме відбувається емуляція атаки з підробкою даних, чому initDataUnsafe не можна використовувати для авторизації, і як правильно реалізувати валідацію рядка initData на Node.js та PHP API з використанням HMAC-SHA-256, перевірки віку підпису auth_date та timing-safe порівняння хешів.
1. Як зловмисник підробляє initDataUnsafe
Об'єкт initDataUnsafe розпарсений на стороні JS-SDK Telegram лише для зручності відображення інтерфейсу (наприклад, підставити ім'я користувача first_name у шапку). Він не містить криптографічного підпису. Якщо ваш вебзастосунок надсилає на сервер дані з initDataUnsafe.user, атака виконується в один рядок коду в консолі браузера.
Приклад підміни даних перед надсиланням запиту:
// Злоумышленник выполняет этот JS-код в WebView / DevTools:
const fakeUser = {
id: 99999999, // ID администратора или жертвы
first_name: "Attacker",
username: "hacker"
};
// Если ваш клиентский код берет payload из initDataUnsafe:
const payload = {
userId: window.Telegram.WebApp.initDataUnsafe.user?.id || fakeUser.id,
action: "get_sensitive_data"
};
// Отправка поддельного запроса на ваш сервер:
fetch('/api/user/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});Якщо бекенд вірить отриманому userId без перевірки оригінального підпису, атакуючий отримує повний доступ до чужого акаунту або прав адміністратора. Щоб цього уникнути, клієнт повинен передавати сирий рядок window.Telegram.WebApp.initData, а бекенд — перевіряти HMAC-SHA-256 хеш.
2. Алгоритм перевірки HMAC-SHA-256 на бекенді
Рядок initData являє собою URL-encoded набір параметрів, розділених амперсандом (&), який містить спеціальне поле hash. Для перевірки автентичності необхідно виконати такі кроки:
- Розпарсити рядок
initDataу словник параметрів. - Витягнути параметр
hashі видалити його зі словника. - Перевірити вік підпису за полем
auth_date. Якщо підпис старіший за допустимий ліміт (наприклад, 24 години), відхилити запит (захист від атаки повторного відтворення — Replay Attack). - Відсортувати решту пар «ключ=значення» в алфавітному порядку та об'єднати їх через символ переведення рядка (
). Отриманий рядок називаєтьсяdata_check_string. - Обчислити секретний ключ:
HMAC-SHA-256("WebAppData", bot_token). Зверніть увагу: рядковий літералWebAppDataвикористовується як ключ, а токен бота — як дані! - Обчислити контрольний хеш:
HMAC-SHA-256(data_check_string, secret_key)у hex-форматі. - Порівняти отриманий хеш із вичисленим раніше
hash, використовуючи функцію, захищену від атак за часом (timing-safe comparison).
3. Захист API на PHP (Laravel / Yii2 / Pure PHP)
Нижче наведено готовий PHP-модуль валідації initData. Код бере токен бота з оточення, виконує всі перевірки та повертає масив із даними авторизованого користувача.
<?php
declare(strict_types=1);
function authenticateTelegramWebAppData(string $rawInitData, int $maxAgeSeconds = 86400): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN environment variable is missing');
}
parse_str($rawInitData, $data);
if (!isset($data['hash']) || !isset($data['auth_date'])) {
throw new InvalidArgumentException('Invalid initData payload: hash or auth_date missing');
}
$providedHash = (string)$data['hash'];
unset($data['hash']);
// Prevention of replay attacks (Replay Attack)
$authDate = (int)$data['auth_date'];
if ((time() - $authDate) > $maxAgeSeconds) {
throw new SecurityException('Authentication payload has expired');
}
// Sort parameters alphabetically
ksort($data);
$dataCheckArray = [];
foreach ($data as $key => $value) {
$dataCheckArray[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArray);
// Compute WebAppData secret key
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
// Timing-safe hash comparison
if (!hash_equals($calculatedHash, $providedHash)) {
throw new SecurityException('Invalid Telegram signature. Access denied.');
}
$user = json_decode($data['user'] ?? '{}', true);
if (!isset($user['id'])) {
throw new InvalidArgumentException('User ID not found in initData');
}
return $user;
}
// Пример использования в контроллере:
try {
$rawInitData = $_POST['initData'] ?? '';
$user = authenticateTelegramWebAppData($rawInitData);
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'user_id' => $user['id']]);
} catch (Throwable $e) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
4. Валідаційний Middleware на Node.js (Express)
Для проєктів на Node.js (Express/Fastify) ми використовуємо вбудований модуль crypto. Важливо використовувати crypto.timingSafeEqual для виключення вразливостей до витоків за часом виконання.
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.json());
function verifyTelegramInitData(initDataRaw, botToken, maxAge = 86400) {
const params = new URLSearchParams(initDataRaw);
const hash = params.get('hash');
if (!hash) return false;
params.delete('hash');
const authDate = parseInt(params.get('auth_date') || '0', 10);
const now = Math.floor(Date.now() / 1000);
if ((now - authDate) > maxAge) {
return false; // Истек срок действия подписи
}
const sortedPairs = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, val]) => `${key}=${val}`);
const dataCheckString = sortedPairs.join('
');
// Хешируем токен бота с помощью ключа "WebAppData"
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
const calculatedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
const calculatedBuffer = Buffer.from(calculatedHash, 'utf8');
const providedBuffer = Buffer.from(hash, 'utf8');
if (calculatedBuffer.length !== providedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(calculatedBuffer, providedBuffer);
}
app.post('/api/auth', (req, res) => {
const { initData } = req.body;
const botToken = process.env.TELEGRAM_BOT_TOKEN;
if (!initData || !botToken) {
return res.status(400).json({ ok: false, error: 'Missing parameters' });
}
if (!verifyTelegramInitData(initData, botToken)) {
return res.status(401).json({ ok: false, error: 'Forged or expired initData' });
}
const params = new URLSearchParams(initData);
const user = JSON.parse(params.get('user') || '{}');
// Генерация сессии / JWT токена
return res.json({ ok: true, userId: user.id });
});
5. Управління MainButton на React: активація строго після HTTP 200
Типова баг-практика фронтенд-розробників — одразу активувати MainButton і довіряти натисканню користувача до перевірки сесії. Правильний патерн вимагає тримати MainButton прихованою або неактивною, поки бекенд не повернув статус 200 ОК на ендпоінт /api/auth.
import React, { useEffect, useState } from 'react';
export function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const tg = window.Telegram?.WebApp;
if (!tg) return;
tg.ready();
tg.MainButton.setText('Оплатить заказ');
tg.MainButton.hide(); // Скрываем до успешной авторизации
const rawInitData = tg.initData;
if (!rawInitData) {
setLoading(false);
tg.showAlert('Приложение должно быть открыто внутри Telegram!');
return;
}
// Отправляем сырую строку initData на валидатор
fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: rawInitData })
})
.then((res) => {
if (!res.ok) throw new Error('HMAC validation failed');
return res.json();
})
.then((data) => {
setIsAuthenticated(true);
// Активируем кнопку только после получения 200 OK
tg.MainButton.show();
tg.MainButton.onClick(() => {
console.log('Безопасное действие для user_id:', data.userId);
});
})
.catch((err) => {
console.error('Ошибка безопасности:', err);
tg.showAlert('Ошибка проверки подлинности аккаунта!');
})
.finally(() => setLoading(false));
}, []);
if (loading) {
return <div>Проверка безопасности Telegram...</div>;
}
return (
<div>
<h1>Каталог товаров</h1>
{isAuthenticated ? (
<p>Вы успешно авторизованы по HMAC-подписи.</p>
) : (
<p style={{ color: 'red' }}>Доступ ограничен. Подпись недействительна.</p>
)}
</div>
);
}
6. Чек-лист безпеки Mini App
- Ніколи не довіряйте
initDataUnsafeна бекенді: використовуйте його тільки для рендерингу плейсхолдерів у UI до відповіді сервера. - Передавайте рядок
initDataу незміненому вигляді: будь-яка зміна порядку ключів або декодування на стороні JS до надсилання зламає HMAC-хеш. - Порівнюйте хеші через timing-safe функції: звичайні оператори
===або==вразливі до timing attacks за часом відгуку сервера. - Обмежуйте TTL підпису за
auth_date: без перевірки часу зловмисник може перехопити чужий валіднийinitDataі використовувати його безстроково.
Якщо вам потрібно розробити надійний Telegram Mini App або інтеграцію будь-якої складності з гарантією безпеки, зверніться до фахівців BotCreator.