Разработчики 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.