Спуфинг initData в Telegram Mini App: подделка user.id и HMAC-валидация на Node.js и PHP

Разработчики 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. Для проверки подлинности необходимо выполнить следующие шаги:

  1. Распарсить строку initData в словарь параметров.
  2. Извлечь параметр hash и удалить его из словаря.
  3. Проверить возраст подписи по полю auth_date. Если подпись старше допустимого лимита (например, 24 часов), отклонить запрос (защита от атаки повторного воспроизведения — Replay Attack).
  4. Отсортировать оставшиеся пары «ключ=значение» в алфавитном порядке и объединить их через символ перевода строки ( ). Полученная строка называется data_check_string.
  5. Вычислить секретный ключ: HMAC-SHA-256("WebAppData", bot_token). Обратите внимание: строковый литерал WebAppData используется как ключ, а токен бота — как данные!
  6. Вычислить контрольный хеш: HMAC-SHA-256(data_check_string, secret_key) в hex-формате.
  7. Сравнить полученный хеш с вычисленным ранее 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.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.