Я пилил пет-проект — небольшой бэкенд на Litestar — и хотел прикрутить к нему логин через Telegram. Открыл первый попавшийся туториал на GitHub: HMAC от bot-token, /setdomain в BotFather, голые поля юзера в callback. Почти всё, что я нашёл, было про старый виджет telegram.org/js/telegram-widget.js.
Открыл официальную доку — а там уже не виджет, а полноценный OpenID Connect через oauth.telegram.org: JWKS, JWT, claims. Сел разбираться. В итоге собрал PoC — он умеет логинить пользователя через новый OIDC, держит cookie-сессию для HTML-страниц и отдаёт пару access + refresh токенов для JSON API.
Эта статья — пересказ того, что мне самому хотелось бы прочитать в начале: где в потоке данных Telegram, где браузер, где наш бэк, и какие куски нужно реально писать руками. По ходу — туториал: настройка бота в BotFather, локальный тест через ngrok, запуск.
Код примеров — из репозитория https://github.com/andy-takker/tg-auth. Стек: Python 3.13, Litestar, PyJWT, SQLAlchemy 2.0, aiosqlite. Но сам OIDC-флоу со стеком не связан — те же шаги повторяются на FastAPI, Django или чём угодно ещё.

Старый виджет ≠ новый OIDC
Если вы открывали старые туториалы про Telegram Login — забудьте их сразу, потоки разные.
Старый виджет (telegram-widget.js):
-
кнопка вставляется на любой
http://-сайт; -
Telegram возвращает поля юзера прямо в URL или в JS-callback;
-
подлинность проверяется HMAC-SHA256 от токена бота;
-
в BotFather вы прописываете домен через
/setdomain.
Если в туториале видите что-то вроде hash = HMAC_SHA256(data_check_string, SHA256(bot_token)) и поля id, first_name, auth_date, hash — это и есть legacy-виджет, закрывайте.
Новый OIDC-флоу (oauth.telegram.org):
-
Telegram стал полноценным OpenID-провайдером;
-
есть
/.well-known/openid-configuration, есть JWKS; -
ID-токен — настоящий JWT, подписанный публичным ключом из JWKS Telegram;
-
бэкенд проверяет подпись по этому ключу, аудиторию (
aud), издателя (iss),exp/iat; -
bot-token при авторизации не используется — он только для Bot API. Не путать его с Client Secret: BotFather в Web Login выдаёт пару Client ID + Client Secret, и Client Secret нужен для manual OIDC code flow. В popup-варианте бэк его не видит, но в полном flow он есть.
Польза от перехода — стандартный протокол, никакой ручной HMAC-проверки legacy-полей и совместимость с любой OIDC-библиотекой. Цена — другой набор настроек в BotFather и обязательный HTTPS на origin’е.
Ещё один нюанс, на который я не сразу обратил внимание. У нового Telegram Login на самом деле два режима:
-
Login library — браузерный popup через
telegram-login.js. Telegram внутри popup-а сам делает Authorization Code Flow с PKCE и возвращает нам уже готовыйid_tokenчерезpostMessage. Бэк только проверяет JWT — никакого Client Secret, никакого PKCE на нашей стороне. -
Manual OIDC — обычный Authorization Code Flow: вы сами редиректите юзера на
oauth.telegram.org/auth, ловитеcodeв своём callback’е и меняете его на токены через/token(Basic Auth с Client ID + Client Secret).
В этой статье — первый вариант. Он проще и для типового веба его достаточно. Manual flow нужен, если у вас нативный клиент без браузера или хочется держать весь OAuth-обмен в своих руках.
Что мы соберём
Минимальный набор маршрутов:
|
Метод |
Путь |
Зачем |
|---|---|---|
|
|
|
Страница логина с виджетом Telegram |
|
|
|
Принимает |
|
|
|
Меняет refresh на новую пару |
|
|
|
Чистит cookie |
|
|
|
Защищённая HTML-страница, читает юзера по cookie |
Cookie-сессия нужна, чтобы HTML-страницы «помнили» юзера между запросами. JWT-пара — для JSON API (мобилка/SPA). Один логин выдаёт сразу обе вещи.
⚠️ Про сами JWT-токены глубоко лезть здесь не буду — в PoC это просто HS256 без хранения в БД. Полноценный refresh-флоу с одноразовостью, family_id и отслеживанием сессий — тема большая и заслуживает отдельной статьи. Здесь упор на сам OIDC-обмен.
Картинка целиком: кто с кем общается
Прежде чем нырять в код, удобно держать в голове ролевую схему. Вот кто участвует и за что отвечает:
Главное, что стоит увидеть на этой схеме: полный OAuth-обмен /auth → /token происходит внутри popup-а Telegram. Бэкенд не делает ни одного запроса к oauth.telegram.org/token, не хранит Client Secret, не возится с PKCE и redirect URI. Получает уже готовый id_token, и его остаётся только проверить.
Что происходит при первом логине: пошагово
Главная sequence-диаграмма статьи. Подробно — что и в каком порядке летает по сети.
Что здесь важно:
1. id_token к нам приходит через postMessage, а не через redirect. Никаких ?code=... в URL, никакого редиректа на /callback. Telegram-popup внутри себя проводит полную OAuth-авторизацию и отдаёт нам уже подписанный JWT через window.postMessage. HTTPS на origin’е нужен не из-за postMessage (он как раз для cross-origin общения и сделан), а потому что Telegram пускает в Trusted Origins только HTTPS-схемы.
2. JWKS — это публичные ключи, по которым мы проверяем подпись JWT. Мы их забираем один раз и кэшируем. PyJWKClient из pyjwt это умеет из коробки:
from jwt import PyJWKClientjwks_client = PyJWKClient( "https://oauth.telegram.org/.well-known/jwks.json", cache_keys=True, lifespan=600, # 10 минут)
В моём приложении этот клиент создаётся один раз при старте и инжектится в use-case через DI.
3. Сама валидация — это один вызов jwt.decode с правильными параметрами. Никакого ручного HMAC-а, никаких сравнений строк:
def _verify_telegram_id_token(id_token: str, jwks_client, client_id, issuer): if not id_token: raise ValueError("Empty id_token") signing_key = jwks_client.get_signing_key_from_jwt(id_token).key return jwt.decode( id_token, signing_key, algorithms=["RS256", "ES256"], audience=client_id, # ваш Client ID из BotFather issuer=issuer, # "https://oauth.telegram.org" options={"require": ["iss", "aud", "exp", "iat", "sub"]}, leeway=30, # на рассинхрон часов )
Если хоть что-то не сошлось — подпись, audience, истёкший срок — jwt.decode бросит исключение, мы переводим его в 401. Всё.
Тут уместная оговорка. В комментариях обязательно спросят: «зачем руками через PyJWT, когда есть authlib?». И да, для прода authlib часто правильный выбор — умеет полноценный OAuth/OIDC-клиент с обменом code → token, кэширует JWKS, проверяет nonce, дружит со Starlette/FastAPI/Flask/Django. Если у вас в проекте уже не один OIDC-провайдер или планируется manual code flow с PKCE — берите authlib, не пишите это руками. Здесь я сознательно пошёл голым PyJWT, чтобы было видно, какие именно claim’ы и какие проверки происходят — и какие из них Telegram-специфичные (никаких).
Один важный пункт, который в моём PoC не реализован — проверка nonce. В проде это делается так: сервер перед открытием popup-а генерирует случайное значение, кладёт его в свою сессию, передаёт в Telegram.Login.init(...), а после валидации id_token сверяет claim nonce с тем, что лежит в сессии. Это защита от replay: перехваченный когда-то id_token не сработает повторно. Если делаете прод — добавьте в options.require строку "nonce" и сравнивайте.
4. После валидации — делаем upsert юзера. В id_token лежат claim’ы: sub (стабильный OIDC-идентификатор пользователя у Telegram), id (числовой Telegram user id, может присутствовать отдельно), name, preferred_username, picture, phone_number (если юзер дал scope phone). В коде я беру id, а если его нет — фолбэк на sub; так чуть надёжнее, чем завязываться на один из двух. Из этих полей собираем запись TelegramAccount и линкуем к User. Логика такая:
-
Ищу
TelegramAccountпоtelegram_id→ если есть, переиспользую его юзера и обновляю мутабельные поля. -
Иначе ищу
Userпоphone_number→ если есть, прикрепляю новыйTelegramAccountк нему. -
Иначе создаю и
User, иTelegramAccount.
Шаг 2 — это то, что позволит позже добавить логин по SMS и не получить дублей юзеров, у которых один и тот же телефон, но два аккаунта.
Туториал: что куда нажимать
Теперь пошагово — как поднять всё это локально.
Шаг 1. Создать бота и настроить OIDC в BotFather
Открываете @BotFather, создаёте бота через /newbot (или используете существующего).

Дальше — самое неочевидное. Настройки OIDC живут не в чате с BotFather, а в его mini-app. В чате нажимаете кнопку «Open» (или иконку приложения), внутри переходите в Bot Settings → Web Login.
Там два важных поля:
-
Trusted Origins — добавьте сюда
https://<ваш-ngrok-домен>.ngrok-free.app. Только origin: ни пути, ни слеша на конце. Origin’ов можно несколько (например,localhostчерез прокси и прод). -
Redirect URIs — для нашего флоу через
telegram-login.jsоставьте пустым. Это поле нужно только если вы сами реализуете server-side обменcode → token.

Команда /setdomain — это от старого виджета. Для OIDC она не нужна.
Шаг 2. ngrok (или любой HTTPS-туннель)
Telegram пускает в Trusted Origins только HTTPS-схемы, поэтому http://localhost:8000 напрямую не подойдёт — нужен HTTPS-туннель наружу.
Самое простое — ngrok:
ngrok http 8000
Получаете URL вида https://abcd-1234.ngrok-free.app. Этот URL кладёте в Trusted Origins в BotFather. На бесплатном тарифе ngrok домен меняется при каждом перезапуске — придётся обновлять Trusted Origins каждый раз. Если играетесь часто — есть смысл купить статический домен или взять Cloudflare Tunnel.

Шаг 3. .env и client_id
В корне проекта есть .env.dev — копируете в .env и заполняете:
APP_TG_CLIENT_ID=8506301481 # ваш Client ID из BotFatherAPP_SECRET_KEY=<32 hex-символа> # ключ AES для подписи cookie-сессии (16/24/32 символа)APP_DB_URL=sqlite+aiosqlite:///./tg_auth.db
Секрет для cookie-сессии у меня в Litestar-овском CookieBackendConfig используется как ключ AES — длина строго 16/24/32 байта. Сгенерировать просто:
python3 -c "import secrets; print(secrets.token_hex(16))" # 32 hex-символа = 32-байтная строка для AES-ключа
Тот же Client ID нужно прописать в data-client-id в tg_auth/presentors/rest/templates/index.html — это атрибут тега <script>, который грузит виджет:
<script async src="https://oauth.telegram.org/js/telegram-login.js?3" data-client-id="8506301481" data-onauth="onTelegramAuth(data)" data-request-access="write phone"></script><button class="tg-auth-button" data-style="shine">Sign In with Telegram</button>
Функция onTelegramAuth(data) — наш callback, в неё Telegram передаёт { id_token, user }. Всё, что она делает:
async function onTelegramAuth(data) { if (!data || data.error) return; const res = await fetch("/api/v1/auth/telegram", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ id_token: data.id_token }), }); if (res.ok) window.location.href = "/app";}
Ничего больше — это весь фронт.
⚠️ Важный момент: в data Telegram кладёт ещё поле user с распакованными полями (имя, аватарка, и так далее). Удобно для UI на фронте, но доверять им нельзя — это та же информация, что и в id_token, только без подписи. Источник истины для бэка — только server-side проверенный id_token. На фронте data.user показывайте, на бэке — игнорируйте.
Шаг 4. Запуск
make develop # создаёт .venv, ставит зависимости через uvmake migrate # применяет alembic-миграцииmake run # python -m tg_auth → uvicorn на :8000
В отдельном терминале — ngrok http 8000. Открываете https-URL ngrok’а, кликаете «Sign In with Telegram», подтверждаете в Telegram-приложении, и должны попасть на /app с вашим именем.


А что во второй раз?
Если cookie уже стоит и она валидная — мы вообще не дёргаем Telegram. Поток такой:
Я держу в сессии буквально {"user": {"id": "<uuid>"}} — больше ничего. Имя/телефон тащу из БД на каждый запрос /app. Это даёт приятный side-эффект: если юзера в БД удалили, контроллер /app это видит, чистит cookie и редиректит на /. В коде — буквально пять строк:
sess_user = request.session.get("user")if not sess_user: return Redirect(path="/")user = await fetch_user_by_id.execute(UserID(UUID(sess_user["id"])))if user is None: request.clear_session() # self-heal return Redirect(path="/")
Access/refresh — пара слов и тизер
Тот же POST /api/v1/auth/telegram после успешного апсерта возвращает и cookie, и пару JWT-токенов:
{ "user": { "id": "...", "name": "Jane", "phone_number": "+7..." }, "tokens": { "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 900 }}
Пара нужна для JSON API — мобильное приложение или SPA положит access в Authorization: Bearer ..., а когда тот протухнет — обменяет refresh на новую пару:
В моём PoC это сделано максимально тупо: HS256, общий секрет, type claim различает access/refresh, никакого хранения в БД. Этого хватает, чтобы продемонстрировать обвязку, но в проде так делать нельзя — нет отзыва, нельзя выкинуть конкретного юзера, при утечке refresh-токена злоумышленник без проблем рефрешится дальше.
Полноценный refresh — это одноразовые токены, family_id для детекции переиспользования, журнал сессий в БД. Тема большая, и про неё я хочу написать отдельно. Здесь — сфокусирован на самом OIDC-обмене.
Грабли, на которые я наступил
Popup открывается, но onTelegramAuth не вызывается. Почти всегда одно из двух: (а) origin не добавлен в Trusted Origins в BotFather, или (б) на сайт отдаётся заголовок Cross-Origin-Opener-Policy: same-origin — он блокирует postMessage из popup-а. Litestar по умолчанию его не ставит, но если у вас впереди nginx/CDN — стоит проверить.
401 Invalid id_token: Audience doesn't match. APP_TG_CLIENT_ID в .env не совпал с data-client-id в index.html. Я на это попадался дважды — поправил в одном месте, забыл в другом. В случае с шаблонами это можно решить одной переменной и прокидывать client_id в шаблон с бэкенда, но с полноценным фронтом в отдельной репе надо будет следить за двумя переменными.
401 Invalid id_token: Signature verification failed. Telegram ротировал ключи в JWKS, а у вас они закэшированы. У PyJWKClient я ставил lifespan=600 — то есть кэш в памяти живёт 10 минут. Простой ребут процесса лечит сразу.
ngrok пересоздал домен — ничего не работает. Бесплатный ngrok даёт новый поддомен на каждый старт. Trusted Origins нужно обновлять каждый раз. Лечится либо платным статическим доменом, либо cloudflared tunnel с привязкой к своему домену.
/app редиректит на / сразу после логина. Cookie ссылается на UUID юзера, которого в БД больше нет (типичная история — снёс файл tg_auth.db после логина). Контроллер сам это увидит и почистит сессию. Просто залогиньтесь снова.
Итог
Новый Telegram OIDC — это история про то, что Telegram перестал быть «своей особенной кнопкой» и стал обычным OpenID-провайдером. Вместо HMAC и /setdomain — JWKS, JWT, claims. Кода на бэке стало меньше: один jwt.decode с правильными параметрами вместо ручной проверки подписи. Цены две — обязательный HTTPS на origin’е и настройки в mini-app BotFather, а не в чате.
Если хотите потрогать руками — код тут: https://github.com/andy-takker/tg-auth. README пошагово описывает запуск, а в tests/ лежит AsyncTestClient-овые проверки на весь HTTP-поток, включая невалидный JWT и self-heal стейл-сессии.
В следующей статье разберу, как сделать refresh-токены так, чтобы за них не было стыдно: одноразовость, family_id, журнал сессий в БД и детекция переиспользования.
ссылка на оригинал статьи https://habr.com/ru/articles/1033632/