Telegram authentication plugin for Better Auth. Login Widget. Mini Apps. OIDC. Link/unlink. HMAC-SHA-256 verification. The whole circus.
Built on Web Crypto API — works in Node, Bun, Cloudflare Workers, and whatever edge runtime you're pretending to need. No node:crypto tantrums.
221 tests. 100% coverage. If it breaks, roast me on X. If it works, also roast me. I'm there either way, posting through the pain.
- Node.js >= 22 (or Bun, or any runtime with Web Crypto API)
better-auth@^1.5.0
npm install better-auth-telegramMessage @BotFather, send /newbot, save the token, then /setdomain with your domain.
For local dev you'll need ngrok because Telegram demands HTTPS. Localhost? Never heard of it.
import { betterAuth } from "better-auth";
import { telegram } from "better-auth-telegram";
export const auth = betterAuth({
plugins: [
telegram({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
botUsername: "your_bot_username", // without @
}),
],
});import { createAuthClient } from "better-auth/client";
import { telegramClient } from "better-auth-telegram/client";
export const authClient = createAuthClient({
fetchOptions: {
credentials: "include", // required for link/unlink
},
plugins: [telegramClient()],
});The plugin adds telegramId, telegramUsername, and telegramPhoneNumber to the user table, and telegramId and telegramUsername to account. If using Prisma:
model User {
// ... existing fields
telegramId String?
telegramUsername String?
telegramPhoneNumber String? // populated via OIDC with phone scope
}
model Account {
// ... existing fields
telegramId String?
telegramUsername String?
}Then npx prisma migrate dev and pray.
authClient.initTelegramWidget(
"telegram-login-container",
{ size: "large", cornerRadius: 20 },
async (authData) => {
const result = await authClient.signInWithTelegram(authData);
if (!result.error) router.push("/dashboard");
}
);// link (user must be authenticated)
await authClient.linkTelegram(authData);
// unlink
await authClient.unlinkTelegram();Getting "Not authenticated"? You forgot credentials: "include". Go back to Client setup.
All API-calling client methods accept an optional fetchOptions parameter for custom headers, cache control, etc:
await authClient.signInWithTelegram(authData, {
headers: { "x-custom-header": "value" },
});authClient.initTelegramWidgetRedirect(
"telegram-login-container",
"/auth/telegram/callback",
{ size: "large" }
);Enable on server:
telegram({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
botUsername: "your_bot_username",
miniApp: {
enabled: true,
validateInitData: true,
allowAutoSignin: true,
},
});Then on client:
// auto sign-in (one less click, revolutionary)
const result = await authClient.autoSignInFromMiniApp();
// or manual
const result = await authClient.signInWithMiniApp(
window.Telegram.WebApp.initData
);
// or just validate without signing in
const validation = await authClient.validateMiniApp(
window.Telegram.WebApp.initData
);Standard OAuth 2.0 flow via oauth.telegram.org. Phone numbers, PKCE, RS256 JWTs — proper grown-up auth instead of widget callbacks. Telegram finally joined the federation.
BotFather has a whole ritual for this. Skipping steps means invalid_client errors and Telegram silently falling back to Login Widget redirects like nothing happened. Don't skip steps.
- Open @BotFather as a mini app (not the chat — the mini app). Go to Bot Settings > Web Login
- Add your website URL. Then remove it. Yes, remove it. Close the panel, open Web Login again — a new option appears: OpenID Connect Login. This is a permanent, one-way switch. Telegram doesn't mention this anywhere because documentation is for the weak
- Go through the OIDC setup flow. It's permanent. No going back. Commitment issues? Too late
- Add your Allowed URL — your website origin (e.g.,
https://example.com). This is the trusted origin for the OAuth flow - Add your Redirect URL — your OIDC callback (e.g.,
https://example.com/api/auth/callback/telegram-oidc). If this isn't registered, Telegram returns auth codes via#tgAuthResultfragment instead of?code=query param, and your server never sees them - Copy your Client ID and Client Secret. They're right there on the screen. The Client Secret is NOT your bot token — BotFather generates a separate secret for OIDC. If you use the bot token, the token endpoint returns
invalid_clientand you'll spend hours debugging something that was never going to work
For local dev, point both URLs at your ngrok tunnel (e.g., https://abc123.ngrok-free.app and https://abc123.ngrok-free.app/api/auth/callback/telegram-oidc). Every time ngrok restarts, you get a new URL. Update BotFather. Repeat until Stockholm syndrome sets in.
See Telegram's official OIDC docs for the spec. It exists now. We're living in the future.
Enable on server:
telegram({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
botUsername: "your_bot_username",
oidc: {
enabled: true,
clientSecret: process.env.TELEGRAM_OIDC_CLIENT_SECRET!, // from BotFather Web Login
requestPhone: true, // get phone numbers, finally
},
});Then on client:
await authClient.signInWithTelegramOIDC({
callbackURL: "/dashboard",
});That's it. Standard Better Auth social login under the hood. PKCE, state tokens, the works. You don't even need to think about it, which is the whole point.
| Option | Default | Description |
|---|---|---|
botToken |
required | From @BotFather |
botUsername |
required | Without the @ |
allowUserToLink |
true |
Let users link Telegram to existing accounts |
autoCreateUser |
true |
Create user on first sign-in |
maxAuthAge |
86400 |
Auth data TTL in seconds (replay attack prevention) |
testMode |
false |
Enable Telegram test server mode |
mapTelegramDataToUser |
— | Custom user data mapper |
miniApp.enabled |
false |
Enable Mini Apps endpoints |
miniApp.validateInitData |
true |
Verify Mini App initData |
miniApp.allowAutoSignin |
true |
Allow auto sign-in from Mini Apps |
miniApp.mapMiniAppDataToUser |
— | Custom Mini App user mapper |
oidc.enabled |
false |
Enable Telegram OIDC flow |
oidc.clientSecret |
— | Client Secret from BotFather Web Login (NOT the bot token) |
oidc.scopes |
["openid", "profile"] |
OIDC scopes to request |
oidc.requestPhone |
false |
Request phone number (adds phone scope) |
oidc.requestBotAccess |
false |
Request bot access (adds telegram:bot_access scope) |
oidc.mapOIDCProfileToUser |
— | Custom OIDC claims mapper |
Full types in src/types.ts.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /telegram/signin |
No | Sign in with widget data |
| POST | /telegram/link |
Session | Link Telegram to account |
| POST | /telegram/unlink |
Session | Unlink Telegram |
| GET | /telegram/config |
No | Get bot config (username, testMode, flags) |
| POST | /telegram/miniapp/signin |
No | Sign in from Mini App |
| POST | /telegram/miniapp/validate |
No | Validate initData |
OIDC uses Better Auth's built-in social login routes — POST /sign-in/social with provider: "telegram-oidc" and GET /callback/telegram-oidc. No custom endpoints needed. Delegation at its finest.
All endpoints are rate-limited. Signin/miniapp: 10 req/60s. Link/unlink: 5 req/60s. Validate: 20 req/60s. Brute-forcing was never a strategy, now it's also a throttled one.
All endpoints throw APIError via APIError.from(). The plugin exposes $ERROR_CODES — each code is a RawError object with code and message properties:
import { telegram } from "better-auth-telegram";
const plugin = telegram({ botToken: "...", botUsername: "..." });
// In your error handler:
if (error.code === plugin.$ERROR_CODES.NOT_AUTHENTICATED.code) {
// handle it
}No more comparing against magic strings. You're welcome.
HMAC-SHA-256 verification on all auth data via Web Crypto API (crypto.subtle). Timestamp validation against replay attacks. Bot token never touches the client. Works in every runtime that implements the Web Crypto standard — which is all of them now, congratulations internet.
Login Widget uses SHA256(botToken) as secret key. Mini Apps use HMAC-SHA256("WebAppData", botToken). Different derivation paths, same level of paranoia.
OIDC adds RS256 JWT verification via Telegram's JWKS endpoint, plus PKCE and state tokens for the OAuth flow. Keys are fetched and matched by kid — no hardcoded secrets, no trust-me-bro validation.
Is it bulletproof? No. Is it better than storing passwords in plain text? Significantly.
Widget not showing? Did you /setdomain with @BotFather? Is botUsername correct (no @)? Does the container exist in DOM? Are you on HTTPS?
Auth fails? Wrong bot token, domain mismatch with BotFather, or auth_date expired (24h default). Check browser console.
Local dev? ngrok http 3000, use the ngrok URL in BotFather's /setdomain and as your app URL. Yes, it's annoying. Welcome to OAuth.
OIDC returns invalid_client? You haven't registered Web Login in @BotFather (Bot Settings > Web Login). Or you're using the bot token as client secret instead of the separate secret BotFather provides. See OIDC Prerequisites.
OIDC redirects with #tgAuthResult instead of ?code=? Your redirect URI isn't registered in BotFather's Web Login Allowed URLs. Telegram falls back to Login Widget redirect mode. Register https://yourdomain.com/api/auth/callback/telegram-oidc in the Allowed URLs.
See examples/nextjs-app/ for a Next.js implementation covering all three auth flows: Login Widget, OIDC, Mini Apps, plus account linking/unlinking. Copy-paste-ready components and server/client setup. There's also a full test playground app in test/ if you want to see everything wired together with a real database.
- OIDC users: Add
oidc.clientSecret— the Client Secret from BotFather's Web Login settings (Bot Settings > Web Login). This is NOT the bot token. Register your Allowed URLs there too, includinghttps://yourdomain.com/api/auth/callback/telegram-oidc. The plugin falls back to bot token ifclientSecretis omitted (with a warning), but Telegram rejects bot tokens as OIDC client secrets. Removed non-standardoriginandbot_idparams from the auth URL. See OIDC Prerequisites. - Login Widget and Mini App flows are unaffected.
- No breaking changes. v1.3.x added graceful
verifyIdTokenfailure, placeholder email generation, diagnosticgetUserInfologging, andoriginparam (now removed in v1.4.0). If you're using OIDC, skip straight to v1.4.0.
- No breaking changes.
testModeis opt-in (defaultfalse).BetterAuthPluginRegistrymodule augmentation is type-only — zero runtime impact. Config endpoint now returnstestModeboolean. Your existing code doesn't care.
- Peer dep bumped to
better-auth@^1.5.0— upgrade better-auth first, then update the plugin. The$ERROR_CODEStype changed fromRecord<string, string>toRecord<string, RawError>and this release follows suit.
- No breaking changes. OIDC is opt-in (
oidc.enabled: falseby default). AddtelegramPhoneNumbercolumn to your user table if you plan to use OIDC with phone scope.
- Verification functions are now async —
verifyTelegramAuth()andverifyMiniAppInitData()returnPromise<boolean>. Slap anawaitin front if you're calling them directly. - Errors throw
APIError— all endpoints throwAPIErrorinstead of returningctx.json({ error }). Switch to Better Auth's standard error shape. - ESM-first —
"type": "module"in package.json. CJS still works via.cjsexports.
Full changelog in CHANGELOG.md.
MIT — do whatever you want. I'm not your lawyer.
Created by Vibe Code.