Secure Telegram Mini App Auth with initData — KEL IT
Telegram Mini Apps 7 min read

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 hash before building the check string
  • Sort key-value pairs alphabetically
  • Use \n as separator, not &
  • Enforce auth_date expiry (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:

  1. Telegram Test Environment — create a test bot via BotFather, open the app through t.me
  2. 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

MistakeImpact
Using initDataUnsafe for authFull account takeover
Wrong data-check-string formatSignature always fails
No auth_date checkReplay attacks
Bot token in frontend bundleAnyone can forge signatures
HTTP backend with HTTPS Mini AppMixed 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.

KEL IT

Need a custom solution?

I build these types of projects professionally. Telegram bots, Mini Apps, websites, mobile and desktop applications. Tell me about your project and I'll get back to you with a plan.