Спуфінг 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

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.