Я пилил пет-проект — небольшой бэкенд на 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 на самом деле два режима:

  1. Login library — браузерный popup через telegram-login.js. Telegram внутри popup-а сам делает Authorization Code Flow с PKCE и возвращает нам уже готовый id_token через postMessage. Бэк только проверяет JWT — никакого Client Secret, никакого PKCE на нашей стороне.

  2. Manual OIDC — обычный Authorization Code Flow: вы сами редиректите юзера на oauth.telegram.org/auth, ловите code в своём callback’е и меняете его на токены через /token (Basic Auth с Client ID + Client Secret).

В этой статье — первый вариант. Он проще и для типового веба его достаточно. Manual flow нужен, если у вас нативный клиент без браузера или хочется держать весь OAuth-обмен в своих руках.


Что мы соберём

Минимальный набор маршрутов:

Метод

Путь

Зачем

GET

/

Страница логина с виджетом Telegram

POST

/api/v1/auth/telegram

Принимает id_token, валидирует, апсертит юзера, ставит cookie + отдаёт access/refresh

POST

/api/v1/auth/refresh

Меняет refresh на новую пару

POST

/api/v1/auth/logout

Чистит cookie

GET

/app

Защищённая 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 PyJWKClient

jwks_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. Логика такая:

  1. Ищу TelegramAccount по telegram_id → если есть, переиспользую его юзера и обновляю мутабельные поля.

  2. Иначе ищу User по phone_number → если есть, прикрепляю новый TelegramAccount к нему.

  3. Иначе создаю и 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 из BotFather
APP_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, ставит зависимости через uv
make 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, журнал сессий в БД и детекция переиспользования.