Secure Telegram Mini App Auth with initData
Telegram Mini Apps launch inside the Telegram client with user data already available via initData. For startups and small teams, that’s a huge win — no signup forms, no OAuth integrations, no email verification flows.
But here’s what trips up most first-time TWA developers: the client is not trustworthy. Anyone can forge a request with someone else’s Telegram user ID if your backend skips signature verification.
This guide covers a production-ready pattern used by checkout flows, booking apps, and SaaS dashboards inside Telegram: React + Vite on the frontend, Node.js on the backend, HMAC validation, and JWT sessions.
The Trust Model: Why Server-Side Validation Matters
When a user opens your Mini App, Telegram injects a query string like:
query_id=AAHdF...&user=%7B%22id%22%3A279058397...&auth_date=1700000000&hash=abc123...
The hash field is an HMAC-SHA256 signature that only your server can verify using the bot token. The user field contains JSON with id, first_name, username, and more.
Reading window.Telegram.WebApp.initDataUnsafe.user and granting access based on that alone is not authentication — it’s security theater. A user with browser DevTools can impersonate anyone.
The rule is simple: send the raw initData string to your backend, verify the signature, then issue your own session token.
Project Setup
mini-app/
├── frontend/ # React + Vite
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ └── api/auth.ts
└── backend/ # Node.js + Fastify
└── src/
├── validateInitData.ts
└── routes/auth.ts
Recommended stack in 2026:
- @telegram-apps/sdk-react — current official React SDK (replaced the legacy
@twa-dev/sdk) - Fastify — lightweight HTTP server
- jose — JWT signing without heavy dependencies
Need a similar Mini App built for your business? Write on Telegram — I’ll review the requirements and suggest a solution.
Frontend: Send Raw initData, Not Parsed User Objects
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install @telegram-apps/sdk-react
Initialize the SDK in main.tsx:
import { init } from '@telegram-apps/sdk-react';
init();
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
Auth flow in App.tsx:
import { useEffect, useState } from 'react';
import { useRawInitData, useLaunchParams } from '@telegram-apps/sdk-react';
import { loginWithInitData } from './api/auth';
export default function App() {
const initData = useRawInitData();
const { platform } = useLaunchParams();
const [user, setUser] = useState<{ id: number; firstName: string } | null>(null);
useEffect(() => {
if (!initData) return;
loginWithInitData(initData).then(setUser);
}, [initData]);
if (!user) return <p>Verifying Telegram credentials…</p>;
return (
<div>
<h1>Welcome, {user.firstName}</h1>
<p>Platform: {platform}</p>
</div>
);
}
The API client sends the full initData string:
export async function loginWithInitData(initData: string) {
const res = await fetch(`${import.meta.env.VITE_API_URL}/auth/telegram`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData }),
});
if (!res.ok) throw new Error('Authentication failed');
const { token, user } = await res.json();
localStorage.setItem('access_token', token);
return user;
}
This pattern works the same whether you’re building a Stripe-powered checkout in the US, a booking tool for EU salons, or a loyalty program for Southeast Asian retail brands — the trust boundary is always on the server.
Backend: HMAC Verification Algorithm
Telegram documents the algorithm in their Web Apps guide:
import crypto from 'node:crypto';
export function validateInitData(initData: string, botToken: string, maxAge = 86400) {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
if (!hash) throw new Error('Missing hash');
params.delete('hash');
const dataCheckString = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(botToken)
.digest();
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
if (calculatedHash !== hash) throw new Error('Invalid signature');
const authDate = Number(params.get('auth_date'));
if (Math.floor(Date.now() / 1000) - authDate > maxAge) {
throw new Error('initData expired');
}
return JSON.parse(params.get('user')!);
}
Critical details developers miss:
- Remove
hashbefore building the check string - Sort key-value pairs alphabetically
- Use
\nas separator, not& - Enforce
auth_dateexpiry (24h is a sensible default)
Issue a JWT After Verification
Once validated, upsert the user in your database and return a session token:
app.post('/auth/telegram', async (request, reply) => {
const { initData } = request.body as { initData?: string };
if (!initData) return reply.status(400).send({ message: 'initData required' });
let tgUser;
try {
tgUser = validateInitData(initData, process.env.BOT_TOKEN!);
} catch {
return reply.status(401).send({ message: 'Unauthorized' });
}
const user = await db.upsertUser({ telegramId: tgUser.id, ...tgUser });
const token = await new SignJWT({ sub: String(user.id) })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
return { token, user };
});
All subsequent API calls use Authorization: Bearer <token>. You only need initData once — at login.
Local Development Without Telegram
initData is empty when you open the app in a regular browser. Two options:
- Telegram Test Environment — create a test bot via BotFather, open the app through t.me
- Dev-only mock endpoint — generates valid initData with a test bot token (never in production)
Never disable signature checks in staging. That’s the #1 security hole in Mini App projects.
Common Pitfalls
| Mistake | Impact |
|---|---|
Using initDataUnsafe for auth | Full account takeover |
| Wrong data-check-string format | Signature always fails |
No auth_date check | Replay attacks |
| Bot token in frontend bundle | Anyone can forge signatures |
| HTTP backend with HTTPS Mini App | Mixed content blocked |
For UI-only purposes (displaying the user’s first name in a header), initDataUnsafe is fine. For anything involving personal data, payments, or order history — server validation is mandatory.
Hook Into Native Telegram UI
After auth succeeds, wire up native controls:
import { mainButton, backButton } from '@telegram-apps/sdk-react';
useEffect(() => {
mainButton.setParams({ text: 'Checkout', isVisible: true });
const off = mainButton.onClick(() => submitOrder());
return () => { off(); mainButton.hide(); };
}, []);
The SDK respects Telegram’s theme (themeParams) automatically. Avoid hardcoded colors — use var(--tg-theme-bg-color) for native-feeling UI across light and dark modes.
Conclusion
Secure Mini App authentication boils down to three steps: frontend sends raw initData, backend verifies HMAC-SHA256 with the bot token, backend issues a JWT session. This pattern scales from solo freelancer projects to funded startups launching inside Telegram’s 900M+ user base.
React + Vite + @telegram-apps/sdk-react handles the frontend in hours. The real engineering effort goes into your user model, session management, and edge cases — expired initData, account linking, and multi-device sessions.
Need help implementing this? I build Telegram Mini Apps professionally. Write on Telegram → or vic.kell@ya.ru
FAQ
Can I authenticate users frontend-only without a backend?
Only for non-sensitive demos. Any app handling personal data, payments, or user-specific content needs server-side signature verification.
How often should I re-send initData?
Once per login. Issue a JWT with a 7–30 day TTL. When it expires, the user reopens the Mini App and Telegram provides fresh initData.
Is Mini App initData the same as Login Widget data?
No. Login Widget uses a different hash algorithm. Always use the WebApp validation flow with "WebAppData" in the HMAC step.
Signature keeps failing — what should I check?
Verify the bot token matches the bot linked to your Mini App, keys are sorted alphabetically, separator is \n, and you’re not double-decoding the query string.
Can I add auth to an existing aiogram bot?
Yes. Add an HTTP route (aiohttp or FastAPI) alongside your bot handlers. Keep initData validation in a dedicated endpoint, not inside message handlers.