Каждый раз, когда пользователь открывает Mini App внутри Telegram, клиент отправляет на бэкенд строку initData - набор параметров с данными о пользователе, чате и моменте запуска, подписанный секретным ключом бота. Валидация initData в Telegram Web App - единственный способ убедиться, что запрос действительно пришёл из Telegram, а не собран руками в devtools кем угодно. Я подключал эту проверку в ботах на aiogram, в бэкендах на Node.js и Laravel, и не раз видел, как разработчики парсят user.id из initData и сразу пишут его в базу - без проверки подписи это открытая дверь для подмены чужого аккаунта.
Что такое initData и почему ей нельзя доверять без проверки
initData передаётся в Mini App через объект window.Telegram.WebApp.initData как обычная query-строка: query_id=AAH...&user=%7B%22id%22...&auth_date=1721990000&hash=c1f4a2.... Внутри - id пользователя, его имя, язык интерфейса, идентификатор чата chat_instance, время выдачи данных auth_date и хеш hash, которым Telegram подписывает всю эту связку.
Проблема в том, что initData приходит с клиента, а клиент - это браузер или Telegram Desktop, где пользователь при желании открывает консоль и меняет любое значение в объекте WebApp до того, как оно уйдёт на сервер. Если бэкенд верит user.id из этой строки без проверки, человек с базовыми навыками подменяет чужой id и получает доступ к чужому профилю, баллам лояльности или заказам в интернет-магазине. Единственный параметр, который реально что-то доказывает, - hash, потому что посчитать его правильно можно только зная токен бота, а токен есть только на вашем сервере.
Алгоритм подписи: HMAC-SHA256 и структура data-check-string
Telegram подписывает initData по схеме из официальной документации Bot API:
- Из строки убирают параметр
hash- он не участвует в подписи, потому что сам является её результатом. - Оставшиеся пары
key=valueсортируют по ключу в алфавитном порядке. - Склеивают их через перевод строки
n- получается data-check-string. - Считают
secret_key = HMAC-SHA256(bot_token, key="WebAppData")- бот-токен подписывают ключом «WebAppData». - Считают
hash = HMAC-SHA256(data-check-string, key=secret_key)и переводят результат в hex. - Сравнивают получившуюся строку с
hashиз initData.
Если строки совпали побайтово - данные точно сгенерированы серверами Telegram и не менялись после этого. Отдельно проверяю auth_date: это unix-время выдачи данных, и если initData «протухла» - например, ей больше суток, - я отклоняю запрос, даже если подпись формально верна: старый initData могли перехватить и переиграть повторно.
С недавних версий Bot API в initData добавили ещё и поле signature - Ed25519-подпись для проверки на стороне сторонних серверов без обращения к боту. На практике для большинства проектов хватает HMAC-SHA256 по hash - он проще в реализации и не требует хранить публичный ключ Telegram.
Проверка подписи на Node.js
На бэкенде с Express или в API-роуте Next.js проверка укладывается в одну функцию. Токен бота храню в переменных окружения, а не в коде - если он утечёт в репозиторий, смысл подписи теряется: подделать hash сможет кто угодно, у кого есть токен.
const crypto = require('crypto');
function validateInitData(initData, botToken) {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
params.delete('hash');
const pairs = [];
for (const [key, value] of params.entries()) {
pairs.push(`${key}=${value}`);
}
pairs.sort();
const dataCheckString = pairs.join('n');
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
const computedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
if (computedHash !== hash) return null;
const authDate = Number(params.get('auth_date'));
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) return null;
return JSON.parse(params.get('user'));
}
В примере хеши сравниваются обычным !== - для большинства ботов и Mini App этого достаточно. Для чувствительных операций (у меня был такой случай в интеграции Mini App с личным кабинетом клиента, где initData открывала доступ к истории заказов) лучше брать crypto.timingSafeEqual - почему, объясню в разделе про частые ошибки.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Проверка подписи на Python (aiogram и FastAPI)
В ботах на aiogram initData чаще всего приходит не из самого бота, а из Mini App, открытого кнопкой web_app, - и проверять её нужно на стороне FastAPI-эндпоинта, который дёргает фронтенд Mini App, а не в хендлере aiogram. Логика та же, оформлена под Python - с parse_qsl и hmac.compare_digest вместо простого сравнения строк.
import hashlib
import hmac
import json
import time
from urllib.parse import parse_qsl
def validate_init_data(init_data: str, bot_token: str, max_age: int = 86400):
parsed = dict(parse_qsl(init_data, strict_parsing=True))
received_hash = parsed.pop('hash', None)
if not received_hash:
return None
data_check_string = 'n'.join(
f'{k}={v}' for k, v in sorted(parsed.items())
)
secret_key = hmac.new(b'WebAppData', bot_token.encode(), hashlib.sha256).digest()
computed_hash = hmac.new(
secret_key, data_check_string.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
return None
auth_date = int(parsed.get('auth_date', 0))
if time.time() - auth_date > max_age:
return None
return json.loads(parsed.get('user', '{}'))
hmac.compare_digest - не косметика, а защита от timing-атаки: обычное сравнение строк прерывается на первом несовпадающем символе, и по разнице во времени ответа теоретически можно по крупицам восстановить правильный hash. Для initData это скорее теоретический риск, но раз constant-time сравнение ничего не стоит, использую его по умолчанию в обоих языках.
Частые ошибки при валидации initData
За несколько проектов с Mini Apps встречал один и тот же набор граблей:
| Ошибка | Чем грозит | Как избежать |
| Сортировка полей без учёта алфавита ключа | hash никогда не совпадает, проверку в панике отключают | Сортировать строго по ключу: Array.sort() в JS, sorted() в Python |
| Забыли декодировать параметры перед сборкой data-check-string | hash не совпадает из-за %7B и похожих последовательностей | Использовать URLSearchParams или parse_qsl - они декодируют автоматически |
| Секретный ключ считают напрямую от токена, без промежуточного HMAC от «WebAppData» | Подпись не сходится никогда, независимо от корректности остального кода | Не пропускать шаг secret_key = HMAC-SHA256(“WebAppData”, bot_token) |
| Не проверяют auth_date | Старый перехваченный initData принимают повторно (replay-атака) | Отклонять данные старше 24 часов, для платежей - 5-10 минут |
| Бот-токен хранится в клиентском коде или публичном репозитории | Подделка подписи третьими лицами | Токен только в .env на сервере, никогда не в git |
Отдельно - про replay-атаки: initData не одноразовая, Telegram не хранит список уже использованных хешей, поэтому теоретически одну и ту же строку можно переслать на сервер второй раз в пределах суток и получить тот же результат валидации. Для операций с деньгами я дополнительно завязываю сессию на query_id и храню его в Redis с TTL, чтобы одну и ту же строку нельзя было применить дважды.
auth_date, срок жизни данных и что проверять после подписи
После совпадения hash работа не заканчивается. auth_date показывает, когда Telegram выдал initData, и это единственная защита от повторного использования старых данных. Для интернет-магазина или личного кабинета обычно даю окно в 24 часа - Mini App открывается заново при каждом заходе в бота, более длинный срок жизни не нужен. Для операций с оплатой (пример из практики - интеграция Mini App с приёмом платежей через Т‑Банк у клиента на WooCommerce с телеграм-фронтом поверх магазина) окно сокращаю до 5-10 минут и требую свежий initData на каждый запрос оплаты.
Ещё одна вещь, которую стоит проверять отдельно, - chat_instance и chat_type: если Mini App открыта из группового чата, а не в личке с ботом, персональные операции логично ограничивать. И да, поле user в initData - это данные, которые пользователь мог сам задать в настройках Telegram (имя, username), доверять им как проверенным паспортным данным нельзя, это просто профиль, а не KYC.
Если собирать модуль валидации с нуля под конкретный стек не хочется - у меня в библиотеке готовых скриптов есть рабочий вариант проверки initData на Node.js и Python, который можно адаптировать под свой бэкенд: готовые скрипты для интеграций с Telegram. Похожая логика проверки подписи пригождается и при обработке данных из Mini App в сценариях n8n - только там HMAC приходится считать в Code-ноде вручную, готового узла под это в n8n нет.
Автоматизация в мессенджере
Telegram-бот / Mini App
от 30 000 ₽
Подробнее →Частые вопросы
Можно ли проверить initData без бэкенда, прямо во фронтенде Mini App?
Нет - секретный ключ считается из токена бота, а токен нельзя класть в клиентский JS, иначе любой человек через devtools достанет его и подделает подпись. Проверка обязательно идёт на сервере, куда фронтенд Mini App шлёт initData отдельным запросом, например на /api/auth.
Что делать, если hash не совпадает, хотя код списан из документации?
В большинстве случаев дело в порядке операций: либо забыли убрать hash перед сборкой data-check-string, либо отсортировали значения не по ключу, а по всей строке key=value целиком, либо используется устаревший bot_token - токен меняется при регенерации через BotFather, и если сервис хранит закешированное старое значение, подпись никогда не сойдётся.
Нужно ли валидировать initData в n8n или можно доверять данным из вебхука?
Если Mini App шлёт данные в n8n через HTTP-ноду напрямую, минуя ваш бэкенд, проверку HMAC придётся делать в Code-ноде на JavaScript тем же алгоритмом - сам n8n ничего не проверяет. Обычно я так не делаю: провожу initData через свой backend, валидирую там, а в n8n отправляю уже проверенный и обогащённый payload вебхуком.
Отличается ли проверка initData для ботов, встроенных в Tilda-страницы?
Если Mini App открывается кнопкой web_app из Telegram, а не как обычная веб-страница на Tilda, initData считается одинаково независимо от того, где размещён фронтенд. Разница только в способе получения строки: на Tilda её обычно вытаскивают кастомным JS-блоком из window.Telegram.WebApp.initData и передают на сервер тем же fetch-запросом, что и на любом другом фронтенде.