Telegram Mini Apps Security: How to Spoof initDataUnsafe and Protect PHP/Yii2 API from Spoofing

Telegram Mini App developers often make a fatal mistake: they trust data coming from the JS interface window.Telegram.WebApp.initDataUnsafe. The name of this object literally screams about its insecurity (Unsafe), but the temptation to quickly get user.id and authorize the user is too great.

In this article, we will look at how an attacker can easily spoof a user ID on the client, provide a working example of a spoofing payload, and write a reliable backend validator in PHP (Yii2) that will block any attempts to bypass authorization. We will also configure the frontend so that the main application button (MainButton) is activated only after a successful session verification by the server.

The illusion of security: why initDataUnsafe cannot be trusted

The initDataUnsafe object is formed inside the Telegram client and is available in the WebView context. Any user who launches your Mini App via the desktop version of Telegram or the web client can open DevTools (developer tools) and manually modify any property of this object before sending a request to your API.

Moreover, an attacker doesn't even need Telegram: they can deploy your Mini App in a regular browser, simulate the window.Telegram.WebApp object, and send any generated data to your server.

Example of a spoofed payload (Spoof Payload)

Below is a typical example of how an attacker can spoof authorization data in the browser console or send it directly to your authorization endpoint:

// Имитация объекта 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
})
});

If your backend does not verify the data signature but simply trusts the passed userId, the attacker instantly gains access to someone else's account, balance, or the Mini App admin panel.

The only right way: validating the raw initData string

For secure authorization, you must pass the raw string window.Telegram.WebApp.initData to the backend. It is a set of query parameters (including hash) separated by an ampersand. The server's task is to verify that this hash was indeed generated by Telegram based on your secret bot token (bot_token).

Writing a validator in Yii2 (PHP)

Let's create a controller in Yii2 that accepts the raw initData string, verifies its authenticity, protects against replay attacks by checking the session lifetime (auth_date), and uses timing-safe string comparison to prevent timing attacks.

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;
}
}

Client-side integration: blocking MainButton until authorization

To protect the application's business logic, we must block the Mini App interface until the backend confirms successful authorization. A great pattern is to keep the main button MainButton hidden or inactive and show it only after receiving an HTTP 200 status from our Yii2 API.

Below is an example of implementation in React using the standard 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;

Important security nuances

  • auth_date lifetime: Always limit the session lifetime. If an attacker intercepts a valid initData string, they can use it indefinitely unless you check the time difference between the server and the auth_date parameter. The optimal limit is 24 hours.
  • Using hash_equals: Regular string comparison using == or === is vulnerable to timing attacks. The hash_equals() function in PHP guarantees that the comparison always takes the same amount of time, regardless of which character the mismatch occurred in.
  • Secret key: Never hardcode the bot token in the codebase. Use переменные окружения (getenv) or secure Yii2 configuration parameters (Yii::$app->params).

If you need professional development of complex Telegram-бот s and Mini Apps, the BotCreator team will help implement a project of any complexity.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.