@telegram-auth/react and @telegram-auth/server are deprecated in favour of Telegram's OpenID Connect login. These packages wrap the iframe-based Telegram Login Widget and its bespoke data validation; the OIDC flow supersedes both.
Because Telegram now speaks plain OIDC, there is nothing left for these packages to do. The bespoke HMAC-over-a-sorted-key-list validation that AuthDataValidator existed to provide is replaced by a standard signed ID token.
The examples below use fetch, the Web Crypto API, and jose for the JWT verification:
npm install joseAny JWKS-aware JWT library will do — jose is used here because it works across Node, edge runtimes and workers. Check your chosen library's own runtime requirements.
Important
Mini App (initData) validation is not covered by OIDC. If you use AuthDataValidator to validate data received via a Telegram Web App (the user/hash payload from window.Telegram.WebApp.initData), OIDC does not replace it — it is a different mechanism for a different surface. See Mini Apps are not affected below.
- What changes
- 1. Get OIDC credentials from BotFather
- 2. Replace
<LoginButton /> - 3. Replace
AuthDataValidator - 4. Wiring it up
- 5. Map the old user fields to the new claims
- Mini Apps are not affected
- Reference: endpoints and parameters
| Legacy (these packages) | Telegram OIDC |
|---|---|
<LoginButton botUsername="..." /> renders a Telegram iframe |
Telegram's login library, or your own button → redirect flow |
| Identified by bot username | Identified by Client ID + Client Secret (from BotFather) |
| Auth data arrives as query params / a JS callback | An ID token (JWT), via the login library or a redirect code |
AuthDataValidator recomputes an HMAC over sorted key=value |
Verify the ID token's signature against Telegram's published keys |
| Trust anchor is your bot token | Trust anchor is Telegram's JWKS |
Freshness via auth_date + inValidateDataAfter |
Freshness via standard exp / iat claims |
| Third-party iframe, no CSRF protection of its own | Authorization Code Flow with PKCE (S256) + state |
The practical upshot: you delete both dependencies, and either hand the flow to an auth framework you already run or implement the two routes in section 4 yourself.
- Open @BotFather.
/mybots→ pick your bot → Bot Settings → Web Login.- Add every redirect URI your app will use (including local dev, e.g.
http://localhost:3000/auth/telegram/callback). Telegram only redirects to registered URIs. - Copy the Client ID and Client Secret from that screen.
Then swap your environment variables:
-BOT_USERNAME=my_login_bot
-BOT_TOKEN=123456:AA...
+TELEGRAM_CLIENT_ID=123456
+TELEGRAM_CLIENT_SECRET=...Keep BOT_TOKEN only if you still call the Bot API or validate Mini App initData; it is no longer part of the login flow.
You have two options, and which one you want depends on how much of the flow you need to control.
Telegram ships a JS library that opens a login popup and hands you back an OIDC id_token. This is the closest analogue to <LoginButton />, and Telegram provides a customisation tool for the button itself. See the login library docs for the script tag and button customiser.
/* `nonce` must come from your server: a random, single-use value it remembers,
so it can check the `nonce` claim on the token it gets back. */
Telegram.Login.auth({ client_id: TELEGRAM_CLIENT_ID, scope: ['profile'], nonce }, (data) => {
if (!data || !data.id_token) {
return; // user dismissed the popup, or an error occurred
}
// Send `data.id_token` to your server and verify it there — see section 3.
});The available methods are Telegram.Login.init(options, callback), Telegram.Login.open(callback) and Telegram.Login.auth(options, callback). options takes client_id, and optionally scope (profile, phone, write), lang and nonce.
Important
The token arrives in the browser, so it must still be verified server-side before you trust it — the same rule that applied to the old widget's callback data. Verify the nonce claim against the value your server issued, or a token obtained for another site can be replayed against yours.
If you want the token never to touch the browser, use the standard Authorization Code flow: your button becomes an ordinary link to a route on your own server.
-import { LoginButton } from '@telegram-auth/react';
-
export function TelegramLogin() {
- return (
- <LoginButton
- botUsername={process.env.NEXT_PUBLIC_BOT_USERNAME!}
- onAuthCallback={(data) => {
- // POST the data to your backend to validate it
- }}
- />
- );
+ return <a href="/auth/telegram">Log in with Telegram</a>;
}Here the client never receives the user data at all — it arrives server-side at your callback route. Section 4 covers the two routes this needs.
Either way, the buttonSize, cornerRadius, showAvatar and widgetVersion props do not carry over; use Telegram's button customiser or style your own. lang has an equivalent in the login library's lang option.
Old: verify an HMAC over the query params, keyed by sha256(botToken).
// before
import { AuthDataValidator } from '@telegram-auth/server';
import { urlStrToAuthDataMap } from '@telegram-auth/server/utils';
const validator = new AuthDataValidator({ botToken: process.env.BOT_TOKEN });
const user = await validator.validate(urlStrToAuthDataMap(request.url));New: exchange the authorization code for an ID token, then verify that token against Telegram's JWKS.
Note
The snippets below show the shape of the flow, not a finished implementation. They leave out error handling, configuration validation, session management and storage — fill those in to your own standards, or let a library do it (see section 4).
The token endpoint authenticates your client with HTTP Basic. Telegram's documented request also carries client_id in the form body, so send both.
const ISSUER = 'https://oauth.telegram.org';
const res = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier, // the PKCE verifier you stashed before redirecting
}),
});
const { id_token } = await res.json();This is the part that replaces validate(). Telegram has no UserInfo endpoint — the ID token is the whole profile, so there is no second request to make.
import * as jose from 'jose';
// Resolves and caches Telegram's signing keys.
const jwks = jose.createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
const { payload: user } = await jose.jwtVerify(id_token, jwks, {
issuer: ISSUER,
audience: CLIENT_ID, // your Bot ID
/* RS256 is Telegram's default. If you changed it under
Login Widget > Advanced in @BotFather, list that instead. */
algorithms: ['RS256'],
requiredClaims: ['exp'],
});That one call covers everything AuthDataValidator did, and the parts you previously had to write yourself: the signature check replaces the HMAC comparison, issuer/audience replace assertions you wrote by hand, and exp replaces inValidateDataAfter. jwtVerify throws on any failure, so a returned payload is a verified one.
Pass those options explicitly — jose only enforces what you ask it to. In particular audience: undefined skips the audience check rather than failing, so make sure that value is actually set, and exp is only enforced when present, hence requiredClaims.
If you used the login library from option A, also check the nonce claim against the value your server issued, and reject the token if it does not match or has already been used.
If you already run an auth framework or identity provider, you probably need none of section 3's code. Most of them accept a generic OIDC provider configured with an issuer URL plus a client ID and secret, then handle discovery, PKCE, state, the token exchange and JWT verification for you. Consult your framework's own documentation for how to register one, and give it the values from the reference table below.
Whatever callback URI the framework uses must be registered with BotFather (see section 1).
Two routes. The first builds the authorization URL and redirects to it:
import { base64url } from 'jose';
const verifier = base64url.encode(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const state = base64url.encode(crypto.getRandomValues(new Uint8Array(16)));
const url = new URL(`${ISSUER}/auth`);
url.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile',
state,
code_challenge: base64url.encode(new Uint8Array(digest)),
code_challenge_method: 'S256',
}).toString();
// Store `verifier` and `state`, then redirect the user to `url`.The second handles Telegram's redirect back: read code and state from the query string, confirm state matches what you stored, then run the exchange and verification from section 3 using the stored verifier.
A few things the sketch above leaves to you, all of which matter:
- Store
verifierandstateserver-side, keyed to the browser session, and make them single-use — read and delete them together when the callback arrives. - Compare
statebefore doing anything else. It is the CSRF defence the legacy widget never had; check it before spending a token exchange on the request. - Give the pending login a short expiry so an abandoned attempt cannot be completed later.
This is exactly the bookkeeping an OIDC library already does, which is why the previous section is the easier road unless you have a reason to avoid the dependency.
validate() returned a TelegramUserData-shaped object. The ID token payload uses standard OIDC claim names:
| Legacy field | ID token claim | Notes |
|---|---|---|
id |
sub (also mirrored as id) |
Prefer sub — it is the standard subject identifier |
first_name |
given_name |
profile scope |
last_name |
family_name |
profile scope |
| — | name |
Full name; profile scope |
username |
preferred_username |
profile scope |
photo_url |
picture |
profile scope |
auth_date |
iat |
Issued-at timestamp |
hash |
— (the JWT signature replaces it) | Verified against the JWKS, not recomputed |
| — | phone_number, phone_number_verified |
phone scope; requires explicit user consent |
If you store Telegram IDs, note that sub is a string while the legacy id was a number — normalise on one before comparing against existing rows.
AuthDataValidator handled two unrelated things, and only one of them is superseded:
- Login Widget data (
id,auth_date,hashquery params) → replaced by OIDC, as above. - Mini App / Web App
initData(user,hash, keyed withHMAC("WebAppData", botToken)) → not replaced. Telegram's OIDC provider is for web sign-in; Mini Apps still hand your server a signedinitDatastring to validate.
If you validate initData, follow the procedure in Telegram's Mini Apps docs. You will still need BOT_TOKEN for that path.
| Item | Value |
|---|---|
| Discovery | https://oauth.telegram.org/.well-known/openid-configuration |
Issuer (iss) |
https://oauth.telegram.org |
| Authorization | https://oauth.telegram.org/auth |
| Token | https://oauth.telegram.org/token |
| JWKS | https://oauth.telegram.org/.well-known/jwks.json |
| UserInfo | none — Telegram does not provide one |
| Response type | code |
| PKCE method | S256 |
| Client auth (token) | HTTP Basic, base64(client_id:client_secret) |
Audience (aud) |
your Bot ID |
| Signing algorithms | RS256 (default), ES256; EdDSA and ES256K for openid scope only. Configurable under Login Widget > Advanced in @BotFather |
| Scopes | openid (required), profile, phone, telegram:bot_access |
The examples above pin RS256 and hardcode the endpoints for clarity. Reading them from the discovery document instead keeps you correct if Telegram moves anything.
Full details: core.telegram.org/bots/telegram-login.