Telegram Mini App developers often make a fatal mistake: trusting the window.Telegram.WebApp.initDataUnsafe object on the client side. Since the client application executes within a WebView (or browser) context, any user can easily open DevTools, substitute user.id with an administrator's or another client's identifier, and send a request to the backend.
In this article, we will demonstrate exactly how a data spoofing attack is emulated, why initDataUnsafe must not be used for authorization, and how to properly implement validation of the initData string in Node.js and PHP APIs using HMAC-SHA-256, auth_date signature age checks, and timing-safe hash comparison.
1. How an Attacker Spoofs initDataUnsafe
The initDataUnsafe object is parsed on the Telegram JS-SDK side merely for UI display convenience (for example, inserting the user's first_name into the header). It does not contain a cryptographic signature. If your web application sends data from initDataUnsafe.user to the server, an attack can be executed in a single line of code in the browser console.
Example of spoofing data before sending a request:
// Злоумышленник выполняет этот 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)
});If the backend trusts the incoming userId without verifying the original signature, the attacker gains full access to someone else's account or administrator rights. To avoid this, the client must pass the raw window.Telegram.WebApp.initData string, and the backend must verify the HMAC-SHA-256 hash.
2. HMAC-SHA-256 Verification Algorithm on the Backend
The initData string is a URL-encoded set of parameters separated by an ampersand (&), including a special hash field. To verify its authenticity, the following steps must be performed:
- Parse the
initDatastring into a parameter dictionary. - Extract the
hashparameter and remove it from the dictionary. - Verify the signature age using the
auth_datefield. If the signature is older than the allowed limit (e.g., 24 hours), reject the request (protection against Replay Attacks). - Sort the remaining "key=value" pairs alphabetically and join them with a newline character (
). The resulting string is calleddata_check_string. - Calculate the secret key:
HMAC-SHA-256("WebAppData", bot_token). Note: the string literalWebAppDatais used as the key, and the bot token as the data! - Calculate the check hash:
HMAC-SHA-256(data_check_string, secret_key)in hex format. - Compare the computed hash with the previously extracted
hashusing a function protected against timing attacks (timing-safe comparison).
3. Securing PHP APIs (Laravel / Yii2 / Pure PHP)
Below is a ready-to-use PHP module for initData validation. The code retrieves the bot token from the environment, performs all checks, and returns an array containing the authorized user's data.
<?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. Validation Middleware for Node.js (Express)
For Node.js projects (Express/Fastify), we use the built-in crypto module. It is essential to use crypto.timingSafeEqual to prevent vulnerabilities related to execution time leaks.
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. React MainButton Management: Activation Strictly After HTTP 200
A typical buggy practice among frontend developers is activating MainButton immediately and trusting the user's click before verifying the session. The correct pattern requires keeping MainButton hidden or inactive until the backend returns a 200 OK status for the /api/auth endpoint.
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 Security Checklist
- Never trust
initDataUnsafeon the backend: use it only for rendering placeholders in the UI before receiving the server response. - Pass the
initDatastring unaltered: any change in key order or decoding on the JS side prior to sending will break the HMAC hash. - Compare hashes using timing-safe functions: standard operators like
===or==are vulnerable to timing attacks based on server response times. - Limit signature TTL using
auth_date: without checking the timestamp, an attacker could intercept someone else's validinitDataand use it indefinitely.
If you need to develop a reliable Telegram Mini App or an integration of any complexity with guaranteed security, contact the specialists at BotCreator.