Telegram authentication plugin for Better Auth. Login Widget. Mini Apps. 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.
117 tests. 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.4.18
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 and telegramUsername to both user and account tables. If using Prisma:
model User {
// ... existing fields
telegramId String?
telegramUsername String?
}
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
);| 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) |
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 |
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 username |
| POST | /telegram/miniapp/signin |
No | Sign in from Mini App |
| POST | /telegram/miniapp/validate |
No | Validate initData |
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 from better-auth/api. The plugin exposes $ERROR_CODES so you can match errors client-side like a civilised person:
import { telegram } from "better-auth-telegram";
const plugin = telegram({ botToken: "...", botUsername: "..." });
// In your error handler:
if (error.message === plugin.$ERROR_CODES.NOT_AUTHENTICATED) {
// 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.
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.
See examples/ for a Next.js implementation.
Breaking changes — read before upgrading:
- 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. - Peer dep bumped — requires
better-auth@^1.4.18.
Full changelog in CHANGELOG.md.
MIT — do whatever you want. I'm not your lawyer.
Created by Vibe Code.