Откройте консоль браузера внутри Mini App, и вы увидите свой числовой Telegram ID. Он лежит в обычном JavaScript-объекте, его можно прочитать, скопировать и подставить в любой запрос. Поэтому наличие этого числа в теле запроса не доказывает ничего: так мог написать и пользователь, и человек, который только что узнал чужой ID. Вся задача серверной проверки сводится к одному вопросу: как отличить данные, которые действительно выдал Telegram вашему боту, от строки, набранной руками.
Ниже разобран путь от сырой строки initData до личного кабинета на Django 5.2: проверка подписи, оценка свежести, собственная сессия и, в конце, проверка прав на каждый объект. Криптографическая часть описана алгоритмом, а не как готовый production-код: для боевого проекта разумнее взять проверенный helper библиотеки и добавить к нему свою политику времени.
Что именно отправлять на сервер
Клиентская часть Mini App даёт два похожих на вид источника данных. Первый — Telegram.WebApp.initDataUnsafe, уже разобранный объект. Второй — Telegram.WebApp.initData, исходная строка. Согласно документации Telegram, на сервер нужно передавать именно строку, а объект initDataUnsafe доверенным считать нельзя. Название тут честное: оно предупреждает заранее.
Причина проста. Подпись считается над точным набором пар «поле=значение». Если клиент пришлёт вам уже распарсенный JSON, вы не сможете воспроизвести исходные байты строки, а значит, не сможете и проверить подпись. Кроме того, объект на клиенте можно изменить до отправки, и сервер никак этого не заметит. Строка же либо совпадает с подписью, либо нет.
Есть и случай, когда строки нет вовсе. Telegram описывает, что при запуске из keyboard button или inline mode WebAppInitData может быть пустым. Для личного кабинета это не повод искать обходной путь: пустая строка не доказывает личность, значит, входа нет. Так же стоит поступить, если в проверенных данных не оказалось user: это поле описано как необязательное, а в кабинете без подтверждённого user.id вам не к чему привязывать права.
Разбор query string: где ломаются подписи
initData — это query string из пар полей и значений. Часть значений сложная, например user: такие поля приходят как сериализованный JSON. На этапе проверки подписи в этот JSON лезть не нужно, он остаётся обычной строкой внутри пары. Разбирать его стоит только после того, как подпись подтверждена.
В Python для разбора подойдёт urllib.parse.parse_qsl: он превращает query string формата application/x-www-form-urlencoded в список пар «имя — значение». У функции несколько параметров, которые здесь важны:
strict_parsing=Trueпревращает ошибки разбора вValueError, и мусорный ввод падает сразу, а не молча искажается;max_num_fieldsограничивает число полей, так что огромная строка от злоумышленника не превратится в нагрузку на разбор;keep_blank_valuesсохраняет пустые значения, а по умолчанию они отбрасываются. Если вы потеряете пустое поле, строка проверки изменится и подпись не сойдётся;encodingиerrorsуправляют переводом процентного кодирования в Unicode, причёмerrorsпо умолчанию равноreplace.
Отдельно стоит предостеречь от двойного декодирования. Функция parse_qsl уже раскодировала значения; прогонять их через unquote или unquote_plus второй раз не требуется, и в контракт разбора это не входит. Лишний проход способен превратить буквальный плюс в пробел или раскрыть процентные последовательности, которых в исходных данных как таковых не было. Строка проверки тогда будет отличаться от той, что считал Telegram, и честная подпись будет отвергнута.
Практическое следствие: валидатор должен возвращать единый отказ на любой странный ввод. Для клиента нет разницы, сломался ли разбор или не совпал hash, и объяснять ему причину не нужно. В журнал стоит записать категорию ошибки, но не саму строку целиком: в ней лежат персональные данные пользователя.
Строка проверки: один hash вынут, остальное остаётся
Теперь нужно собрать data-check-string. Правило из документации Telegram: поля сортируются по алфавиту, каждое записывается как key=value, пары соединяются символом перевода строки LF (0x0A). Без завершающего перевода строки и без пробелов вокруг.
Самое тонкое место здесь — какие поля исключать. Читаем опубликованную реализацию check_webapp_signature в исходном коде aiogram: она требует наличия hash, извлекает и удаляет только его, затем сортирует оставшиеся поля и склеивает их через LF. Все остальные поля остаются в строке. Если в данных есть поле signature, в HMAC-варианте оно тоже участвует в строке проверки.
Это стоит выделить, потому что в одном месте документации Telegram описаны сразу два алгоритма, и их легко перепутать:
| HMAC (владелец бота) | Ed25519 (третьи стороны) | |
|---|---|---|
| Кто проверяет | Сервер, знающий bot token | Сторона без токена, с публичным ключом Telegram |
| Что сравнивается | Поле hash | Поле signature |
| Что исключается из строки | Только hash | hash и signature |
| Что добавляется в начало | Ничего | bot_id:WebAppData перед строкой |
Ваш личный кабинет принадлежит владельцу бота, поэтому нужна левая колонка. Частая ошибка выглядит так: разработчик находит где-то правило «убираем hash и signature», применяет его к HMAC-проверке, и подпись начинает расходиться у тех клиентов, чьи данные содержат signature. Правило верное, но для другого алгоритма. Здесь Ed25519 нам не нужен, и в этой статье он не разбирается.
Два HMAC и одно место, где путают hex с байтами
Теперь сама криптография. Она короткая, но каждая деталь важна. Приведу схему в виде фрагмента, который повторяет опубликованную реализацию aiogram; это иллюстрация логики, а не готовый модуль для продакшена:
secret_key = hmac.new(key=b"WebAppData", msg=token.encode(), digestmod=sha256)
calculated = hmac.new(key=secret_key.digest(), msg=data_check_string.encode(), digestmod=sha256).hexdigest()
ok = hmac.compare_digest(calculated, received_hash)Что здесь происходит, по шагам:
- Первый HMAC-SHA-256 берёт ключом константу
WebAppData, а сообщением — токен бота. То есть токен выступает не ключом, а данными. Именно эту перестановку часто упускают, интуитивно ставя токен на место ключа. - Результат первого шага — это и есть секретный ключ для второго. Брать нужно его двоичный вид, то есть
digest(). По документации модуля hmac,digestвозвращает байты, аhexdigest— строку шестнадцатеричных цифр, и это разные представления. Если передать вторым ключом hex-строку, вы получите другой, совершенно валидный на вид HMAC, который никогда не совпадёт. - Второй HMAC-SHA-256 считается над строкой проверки. Только его итог переводится в hex.
- Этот hex сравнивается с полем
hash, пришедшим от клиента.
Отдельно обратите внимание, чего здесь нет. В этом алгоритме не используется SHA256 от токена бота: такая схема встречается в другом протоколе авторизации Telegram, и переносить её сюда нельзя. Для Mini App ключ строится именно через HMAC с ключом WebAppData.
Об аргументах hmac.new: ключ передаётся как bytes или bytearray, а алгоритм digestmod обязателен. Строку проверки перед хешированием нужно закодировать в байты, а не пытаться подать str.
Сравнение без оператора равенства
Заключительный штрих — как именно сравнивать два hex-значения. Документация Python для проверки внешнего digest рекомендует hmac.compare_digest вместо ==, чтобы снизить риск атак по времени сравнения. Обычное равенство завершается на первом расхождении, и по времени ответа теоретически можно судить о длине совпавшего префикса.
Есть оговорка про типы: compare_digest требует аргументов одного типа — либо оба ASCII-str, либо оба bytes-подобные. Если hash пришёл с не-ASCII символами, а вы подали строку, функция выбросит ошибку. Поэтому полученное поле стоит обрабатывать так, чтобы любое исключение превращалось в обычный отказ. Для разных длин или типов, как сказано в документации, возможна утечка сведений о длине или типе, но не о содержимом, и для hex-digest фиксированной длины это не проблема.
В aiogram именно так всё и устроено: сравнивается hexdigest с переданным hash через hmac.compare_digest. А безопасная обёртка safe_parse_webapp_init_data сначала проверяет подпись, затем разбирает данные и при провале выбрасывает ValueError. Её «небезопасный» собрат parse_webapp_init_data прямо помечен как разбор без проверки безопасности. В коде личного кабинета годится только первый вариант.
Подпись верна. А когда она была выдана?
Есть то, чего подпись сама по себе не умеет. Она доказывает, что строку выдал Telegram вашему боту, но не говорит, что это произошло минуту назад. Строку можно перехватить из лога, из истории прокси или из присланного скриншота отладчика, и она будет проверяться успешно ещё долго.
Опубликованная функция check_webapp_signature в aiogram не проверяет текущее время, максимальный возраст или повторное использование: она отвечает только за подпись. Значит, свежесть остаётся задачей вашего приложения.
Для этого служит поле auth_date — Unix timestamp, момент открытия или получения данных Mini App. Telegram предлагает проверять давность, но универсального числа не называет: это политика приложения. Выбирать придётся самому, и вот на что опираться:
- Кабинет с чувствительными данными. Окно можно сделать коротким, например несколько минут. Условный пример: допустимый возраст 300 секунд плюс небольшой запас на расхождение часов. Цифры здесь иллюстративные, подбирать их нужно под свой риск.
- Долго открытое приложение. Пользователь мог оставить Mini App висеть на час. Но это не повод расширять окно: вход происходит один раз при загрузке, а дальше работает ваша сессия.
- Часы сервера. Если время на VPS уходит, честные пользователи начнут получать отказ. Синхронизация часов — часть проверки, а не отдельная забота.
- Будущее.
auth_dateсильно больше текущего времени тоже подозрителен; небольшой допуск оправдан, большой — нет.
Нужно ли запоминать использованные строки и отвергать повтор? Это отдельное решение. Поле query_id описано как необязательное и предназначено для answerWebAppQuery; считать его обязательным одноразовым значением нельзя. Если вам нужна строгая одноразовость входа, придётся хранить хеш принятой строки или собственный маркер. Для большинства кабинетов хватает короткого окна: после входа вы всё равно выдаёте собственную сессию, и украденная строка работает лишь в пределах этого окна.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Выдаём свою сессию и забываем про initData
После проверки подписи и возраста у вас есть проверенный user.id. Теперь важный архитектурный шаг: initData больше не нужен. Отправлять его с каждым запросом и перепроверять — возможный, но неудобный путь; обычно после успешного входа сервер выдаёт собственную сессию, и дальше личность определяется ей.
Тут стоит помнить, что у двух механизмов разные сроки жизни. Telegram-окно говорит, как давно открыт Mini App. Время жизни сессии — отдельная настройка Django, и она вообще не связана с initData.
В Django 5.2 по умолчанию данные сессии хранятся на сервере в базе, а в cookie лежит лишь идентификатор. Для кабинета это разумный выбор, и вот почему:
- выход пользователя действительно уничтожает запись. Стандартный
logoutвызываетflush, который удаляет данные сессии и cookie; - у варианта signed-cookie всё иначе: данные подписаны, но не зашифрованы, свежесть не гарантируется, и украденная cookie не отзывается при выходе до истечения срока. Для входа через Telegram такой компромисс лучше не принимать;
- сессия только в кеше может пропасть при вытеснении или перезапуске кеша. Вариант
cached_dbпишет и в БД, и в кеш, а при отсутствии записи в кеше читает БД.
Сам вход оформляется через django.contrib.auth.login. Эта функция вызывает cycle_key, который создаёт новый ключ сессии с сохранением данных, и тем самым защищает от фиксации сессии. Так что порядок такой: сервер проверил подпись, нашёл или создал локального пользователя по user.id и вызвал login.
Про идентификатор: WebAppUser.id может превышать 32 бита, поэтому колонку в базе нужно делать достаточно широкой (например, BigInteger), а не обычным 32-битным целым. Имя пользователя (username) необязательно, поэтому его нельзя делать ключом входа. Для уникальности используйте сам идентификатор: ограничение UNIQUE в PostgreSQL по умолчанию не считает один NULL равным другому, так что обязательность задаётся отдельно через NOT NULL. Для колонки с Telegram ID нужны оба условия.
CSRF: другой вопрос, другая защита
После входа браузер хранит cookie сессии и сам прикладывает её к запросам. И вот здесь возникает иллюзия: «мы же проверили подпись, значит, запросы безопасны». Это не так. Успешная HMAC-проверка подтверждает личность в момент входа, но не защищает от того, что чужая страница заставит браузер отправить запрос с вашей cookie.
За это отвечает CSRF-защита Django. Она касается нежелательных действий через браузер с учётными данными вошедшего пользователя; документация рассматривает и login CSRF. Практические следствия:
- безопасные методы, вроде GET, не должны менять данные; опасные (POST, PUT, DELETE) защищаются отдельно;
CsrfViewMiddlewareпроверяет cookie и токен для небезопасных запросов, а секрет меняется при входе пользователя;- middleware проверяет заголовок Origin относительно текущего хоста и
CSRF_TRUSTED_ORIGINS; при HTTPS без Origin выполняется строгая проверка Referer.
И обратное верно не менее: CSRF-токен не подтверждает, что вы Telegram-пользователь с таким-то user.id. Это два независимых слоя, и ни один не заменяет другой.
Для запросов из JavaScript документация Django описывает передачу токена в заголовке X-CSRFToken. Способ получения токена зависит от CSRF_USE_SESSIONS и CSRF_COOKIE_HTTPONLY; если на странице нет формы, может понадобиться ensure_csrf_cookie. В примере документации токен отправляется только same-origin, чтобы не передать его чужому домену. Для самого входа тоже полезно продумать порядок: эндпоинт принятия initData — это POST, и вопрос, как клиент получит токен до входа, нужно решить заранее.
Проверять эту защиту тестами нужно осознанно: стандартный клиент Django ослабляет CSRF-проверки, и защиту покажет только Client(enforce_csrf_checks=True). Иначе тест будет проходить, а в реальности запрос без токена окажется принят.
Вошёл — не значит можно всё
Остался самый частый источник утечек. Допустим, вход безупречен: подпись верна, свежесть проверена, сессия выдана, CSRF включён. Пользователь Анна открывает свою заявку по адресу вроде /api/requests/1042/. Что мешает ей заменить число на 1043?
Если ничего, это классический IDOR, о котором говорит памятка OWASP: пользователь меняет ссылку или ID, а сервер не проверяет доступ к конкретному объекту. Там же сказано, что сложный или случайный идентификатор не спасает: его трудно угадать, но контроль доступа он не заменяет. Заменить автоинкремент на UUID — не решение, а косметика.
Рекомендованная схема из той же памятки — три шага:
- текущего пользователя определять из сессии, а не из параметра запроса или тела;
- ограничивать выборку набором объектов, доступных этому пользователю;
- проверять разрешение при каждом доступе, а не только при открытии списка.
В Django это обычно выглядит как фильтр по владельцу прямо в запросе, а не как проверка после загрузки. Условный пример: вместо поиска заявки по одному pk вы ищете её по паре «pk и владелец — текущий пользователь». Для чужого номера результата не будет, и ответ будет таким же, как для несуществующего объекта. Это заодно не выдаёт, что заявка с таким номером вообще есть.
Один тест на это не покрывается. Памятка советует проверять двумя пользователями и объектами каждого, причём по всем операциям: чтение, создание, изменение, удаление, экспорт и административные действия. Типичный провал выглядит так: чтение закрыто, а экспорт в файл или кнопка «отменить» проверяет только существование заявки.
Что может и чего не может база
Часть работы можно переложить на PostgreSQL, но не всю. Привилегии SELECT, INSERT, UPDATE и DELETE в PostgreSQL выдаются раздельно; это полезно, если роли веб-приложения не нужно удалять строки вообще. Но это права роли базы, а не пользователя Telegram.
Row-Level Security из раздела о политиках строк ограничивает доступ к отдельным строкам дополнительно к обычным привилегиям. Без включённой RLS доступ к таблице не привязывает пользователя к владельцу строки. Если RLS включена, а подходящей политики нет, действует запрет по умолчанию. Но есть ограничения, которые часто упускают:
- суперпользователи и роли с
BYPASSRLSобходят политики; - владелец таблицы обычно тоже обходит их, если не включён
FORCE ROW LEVEL SECURITY; - если приложение ходит в базу через общий пул под одной ролью, база не знает, что за пользователь Telegram сейчас делает запрос. Политике нужно как-то передать эту информацию, и это отдельная инженерная задача.
Внешний ключ тоже не защита: он, как сказано в документации по ограничениям, подтверждает ссылочную целостность, а не разрешение текущего пользователя на связанную запись. То, что заявка существует и ссылается на услугу, не означает, что её можно читать. Поэтому надёжнее всего начинать с проверки прав в слое приложения, а возможности базы добавлять как второй рубеж.
Где всё это запускать
Для описанной связки не нужен тяжёлый стек: Django, база и обратный прокси вполне уживаются на небольшом сервере. Токен бота должен лежать в окружении сервера, а не в клиентском коде, и root-доступ к VPS позволяет самому решить, как хранить секреты и настраивать синхронизацию часов — а от неё, как мы видели, зависит проверка auth_date. Для маленького кабинета можно начать с тарифа вроде Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) на VPS MAATRIX; для приложения с базой и воркерами пригодятся Uab или Heka. Эти ресурсы не равны обещанию ёмкости: реальную нагрузку нужно мерить на своём приложении. Если вы строите запись на услуги, стоит заглянуть в разбор Telegram Mini App для записи на услуги: там та же задача, но с расписанием и бронированием.
Одна заявка, которой там быть не должно
Соберём цепочку в одном воображаемом запросе. Анна открывает Mini App; клиент отправляет сырую initData. Сервер разбирает строку, убирает hash, сортирует остальные поля, считает два HMAC и сравнивает результат через compare_digest. Затем проверяет auth_date по своей политике, находит локального пользователя по user.id и вызывает login. Дальше личность определяет сессия.
Теперь Борис, у которого тоже есть подлинная подпись и своя сессия, шлёт запрос на /api/requests/1042/ — заявку Анны. Подпись Telegram у него настоящая, CSRF-токен правильный, сессия живая. И всё равно сервер ищет заявку среди объектов, принадлежащих пользователю из сессии, ничего не находит и отвечает так, будто такой заявки нет. Подлинная подпись отвечала на вопрос «кто ты». Вопрос «чьё это» решается в отдельной проверке, и именно она остановила Бориса.