Разработчики Telegram Mini Apps часто совершают фатальную ошибку: они доверяют данным, которые приходят из JS-интерфейса window.Telegram.WebApp.initDataUnsafe. Название этого объекта буквально кричит о его небезопасности (Unsafe), однако соблазн быстро получить user.id и авторизовать пользователя слишком велик.
В этой статье мы разберем, как злоумышленник может легко подделать идентификатор пользователя на клиенте, предоставим рабочий пример спуфинг-нагрузки и напишем надежный бэкенд-валидатор на PHP (Yii2), который пресечет любые попытки обхода авторизации. Также мы настроим фронтенд так, чтобы главная кнопка приложения (MainButton) активировалась только после успешной проверки сессии сервером.
Иллюзия безопасности: почему initDataUnsafe нельзя верить
Объект initDataUnsafe формируется внутри Telegram-клиента и доступен в контексте WebView. Любой пользователь, запустивший ваше Mini App через десктопную версию Telegram или веб-клиент, может открыть DevTools (инструменты разработчика) и вручную изменить любое свойство этого объекта перед отправкой запроса на ваш API.
Более того, злоумышленнику даже не нужен Telegram: он может развернуть ваше Mini App в обычном браузере, сымитировать объект window.Telegram.WebApp и отправлять любые сгенерированные данные на ваш сервер.
Пример поддельной полезной нагрузки (Spoof Payload)
Ниже представлен типичный пример того, как злоумышленник может подделать данные авторизации в консоли браузера или отправить их напрямую на ваш эндпоинт авторизации:
// Имитация объекта WebApp с подмененным user.id администратора
const spoofedTelegramContext = {
initDataUnsafe: {
query_id: "AAH_fake_query_id",
user: {
id: 99999999, // ID администратора или целевой жертвы
first_name: "Hacker",
last_name: "",
username: "evil_hacker",
language_code: "ru"
},
auth_date: Math.floor(Date.now() / 1000),
hash: "fake_hash_that_will_fail_proper_validation"
}
};
// Если ваш API принимает только user.id из тела запроса:
fetch('https://your-api.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: spoofedTelegramContext.initDataUnsafe.user.id
})
});Если ваш бэкенд не проверяет подпись данных, а просто верит переданному userId, злоумышленник мгновенно получает доступ к чужому аккаунту, балансу или админ-панели Mini App.
Единственный верный путь: валидация сырой строки initData
Для безопасной авторизации необходимо передавать на бэкенд сырую строку window.Telegram.WebApp.initData. Она представляет собой набор query-параметров (включая hash), разделенных символом амперсанда. Задача сервера — проверить, что этот хэш действительно был сгенерирован Telegram на основе вашего секретного токена бота (bot_token).
Пишем валидатор на Yii2 (PHP)
Создадим контроллер в Yii2, который принимает сырую строку initData, проверяет ее подлинность, защищает от replay-атак с помощью проверки времени жизни сессии (auth_date) и использует безопасное по времени сравнение строк (timing-safe comparison) для предотвращения атак по времени.
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\BadRequestHttpException;
use yii\web\UnauthorizedHttpException;
class AuthController extends Controller
{
// Отключаем CSRF для API-запросов авторизации
public $enableCsrfValidation = false;
/**
* Авторизация пользователя по initData
*/
public function actionLogin()
{
Yii::$app->response->format = Response::FORMAT_JSON;
$request = Yii::$app->request;
// Получаем initData из заголовка Authorization или POST-параметров
$authHeader = $request->getHeaders()->get('Authorization');
$initData = '';
if ($authHeader && preg_match('/^Bearer\s+(.*?)$/i', $authHeader, $matches)) {
$initData = $matches[1];
} else {
$initData = $request->post('initData', '');
}
if (empty($initData)) {
throw new BadRequestHttpException('Отсутствуют данные авторизации (initData)');
}
try {
$userData = $this->validateInitData($initData);
// Здесь вы можете найти пользователя в БД по $userData[\'id\'] или создать нового
// И выдать JWT-токен или установить сессию
return [
'success' => true,
'user' => $userData,
];
} catch (\Exception $e) {
Yii::warning('Попытка несанкционированного доступа: ' . $e->getMessage(), 'telegram_auth');
throw new UnauthorizedHttpException($e->getMessage());
}
}
/**
* Валидация строки initData по алгоритму Telegram
*/
private function validateInitData(string $initData): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
throw new \Exception('Токен бота не сконфигурирован на сервере');
}
// 1. Парсим query-строку
parse_str($initData, $params);
if (!isset($params['hash']) || !isset($params['auth_date'])) {
throw new \Exception('Неверный формат initData: отсутствует hash или auth_date');
}
$tgHash = $params['hash'];
unset($params['hash']); // Хэш не участвует в формировании подписи
// 2. Проверяем актуальность данных (защита от replay-атак)
// Разрешаем сессии не старше 24 часов (86400 секунд)
$authDate = (int)$params['auth_date'];
if (time() - $authDate > 86400) {
throw new \Exception('Срок действия сессии истек (auth_date expired)');
}
// 3. Сортируем параметры по алфавиту (ksort)
ksort($params);
// 4. Формируем строку проверки данных (data_check_string)
$dataCheckArr = [];
foreach ($params as $key => $value) {
$dataCheckArr[] = "{$key}={$value}";
}
$dataCheckString = implode("\n", $dataCheckArr);
// 5. Генерируем секретный ключ на основе токена бота
// Используем константную строку "WebAppData" в качестве соли
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
// 6. Вычисляем проверочный хэш
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
// 7. Безопасное сравнение хэшей (защита от атак по времени)
if (!hash_equals($calculatedHash, $tgHash)) {
throw new \Exception('Ошибка валидации подписи. Данные подделаны!');
}
// Декодируем объект пользователя
if (!isset($params['user'])) {
throw new \Exception('Данные пользователя отсутствуют в initData');
}
$user = json_decode($params['user'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Ошибка декодирования JSON пользователя');
}
return $user;
}
}Интеграция на клиенте: блокировка MainButton до авторизации
Чтобы защитить бизнес-логику приложения, мы должны заблокировать интерфейс Mini App до тех пор, пока бэкенд не подтвердит успешную авторизацию. Отличный паттерн — держать главную кнопку MainButton скрытой или неактивной и показывать ее только после получения HTTP-статуса 200 от нашего Yii2 API.
Ниже приведен пример реализации на React с использованием стандартного SDK:
import React, { useEffect, useState } from 'react';
const MiniApp = () => {
const [authorized, setAuthorized] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const tg = window.Telegram?.WebApp;
if (!tg) {
setError('Запустите приложение внутри Telegram');
setLoading(false);
return;
}
// Инициализация: скрываем главную кнопку
tg.MainButton.hide();
const rawInitData = tg.initData;
if (!rawInitData) {
setError('Не удалось получить данные авторизации Telegram');
setLoading(false);
return;
}
// Отправляем сырые данные на наш Yii2 бэкенд
fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData: rawInitData })
})
.then(async (response) => {
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Ошибка авторизации на сервере');
}
return response.json();
})
.then((data) => {
if (data.success) {
setAuthorized(true);
// Активируем и показываем MainButton только после успешного ответа API
tg.MainButton.setText('Оформить заказ');
tg.MainButton.show();
tg.MainButton.onClick(() => {
tg.showAlert('Заказ оформлен!');
});
}
})
.catch((err) => {
setError(err.message);
tg.showAlert(`Критическая ошибка: ${err.message}`);
})
.finally(() => {
setLoading(false);
});
}, []);
if (loading) return <div>Загрузка и проверка сессии...</div>;
if (error) return <div style={{ color: 'red' }}>Ошибка: {error}</div>;
return (
<div style={{ padding: '15px' }}>
<h1>Добро пожаловать в магазин!</h1>
<p>Сессия успешно подтверждена сервером. Кнопка внизу экрана активна.</p>
</div>
);
};
export default MiniApp;Важные нюансы безопасности
- Срок жизни auth_date: Всегда ограничивайте время жизни сессии. Если злоумышленник перехватит валидную строку
initData, он сможет использовать ее бесконечно, если вы не проверяете разницу во времени между сервером и параметромauth_date. Оптимальный лимит — 24 часа. - Использование hash_equals: Обычное сравнение строк через
==или===уязвимо к атакам по времени (Timing Attacks). Функцияhash_equals()в PHP гарантирует, что сравнение всегда занимает одинаковое количество времени, независимо от того, в каком символе произошло несовпадение. - Секретный ключ: Никогда не хардкодьте токен бота в кодовой базе. Используйте переменные окружения (
getenv) или защищенные параметры конфигурации Yii2 (Yii::$app->params).
Если вам нужна профессиональная разработка сложных Telegram-ботов и Mini Apps, команда BotCreator поможет реализовать проект любой сложности.