Разработка · 7 мин чтения

Валидация initData в Telegram Web App: как проверить подпись на сервере

Каждый раз, когда пользователь открывает 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:

  1. Из строки убирают параметр hash - он не участвует в подписи, потому что сам является её результатом.
  2. Оставшиеся пары key=value сортируют по ключу в алфавитном порядке.
  3. Склеивают их через перевод строки n - получается data-check-string.
  4. Считают secret_key = HMAC-SHA256(bot_token, key="WebAppData") - бот-токен подписывают ключом «WebAppData».
  5. Считают hash = HMAC-SHA256(data-check-string, key=secret_key) и переводят результат в hex.
  6. Сравнивают получившуюся строку с 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-запросом, что и на любом другом фронтенде.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Самозанятый Калинкин Н. А. · работаю с физлицами и юрлицами

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно