Authenticating React Telegram Mini Apps with InitData and JWT

Introduction

A Telegram Mini App is a lightweight web application hosted inside a Telegram chat, accessible via t.me/Bot?start=. Unlike full bots, Mini Apps cannot run persistent processes; they rely entirely on incoming messages and callback queries. When building a React frontend for a Mini App, you must authenticate the app's identity before trusting any data it sends back—especially the user_id field found in the initData payload. This guide walks through a secure, end-to-end pattern: the PHP backend validates the initData signature, extracts the authenticated user ID, issues a signed JWT, and the React client uses that JWT to prove its identity when making subsequent requests.

The challenge is twofold. First, the initData can be forged if you don't verify its cryptographic signature. Second, even a legitimate initData could carry a spoofed user_id if the backend isn't careful. By combining a proper HMAC signature scheme with a short-lived JWT issued only after backend verification, you create a defense-in-depth flow that prevents impersonation while keeping the experience smooth for users.

The InitData Flow

When a user opens your Mini App, Telegram sends a POST request to your webhook containing initData and callback_query. The initData is a base64-encoded string that contains:

{
"session_id": "unique-session-id",
"user_id": 123456789,
"init_data": {
"action": "main_button",
"params": {}
}
}

The user_id here is not the Telegram user ID—it is the identifier assigned by your backend to the session. It is set during the initial login flow (see below). Because it is embedded in the initData, it travels with every message the Mini App receives, but without verification it could be manipulated.

Why Verify initData?

Without verification, a malicious actor could craft a fake initData with their own user_id, potentially hijacking sessions or triggering actions on behalf of other users. Even if the user_id matches a known value, an attacker might try to replay old initData entries. Verifying the signature ensures the payload originated from your server.

Backend: Validating initData and Issuing JWT

The PHP endpoint that handles webhooks must perform three steps:

1. Verify the HMAC signature of initData using a shared secret. 2. Extract the embedded user_id and ensure it hasn't been revoked. 3. Issue a JWT that encodes the user ID and expiration time, which the React client will later present in the Authorization header.

Below is a minimal but complete implementation. The secret is stored in environment variables (TELEGRAM_INITDATA_SECRET) and never hardcoded.

<?php
require 'vendor/autoload.php';

use Illuminate\HttpRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;

// Configuration
const INITDATA_SECRET = env('TELEGRAM_INITDATA_SECRET');
const JWT_SECRET = env('JWT_SECRET'); // used for signing the JWT

// In-memory store for active sessions (replace with Redis/DB in production)
$activeSessions = [];

/**
* Handles the webhook from the Telegram Mini App.
* Expects a POST with initData and optionally a callback_query.
*/
public function handleWebhook(\$request): \\(HttpResponse\)\{ ... } // simplified
{
// 1. Parse initData
$initData = $request->input('initData', '');
if (empty($initData)) {
return response()->json(['error' => 'missing_initData'], 400);
}

// 2. Verify HMAC signature
$signature = $request->input('init_data_signature', '');
$expectedSignature = Hash::create(INITDATA_SECRET, $initData)->getHmac();
if (!hash_equals($expectedSignature, $signature)) {
return response()->json(['error' => 'invalid_initdata_signature'], 401);
}

// 3. Decode initData (base64url)
$decoded = base64_decode($initData, true);

// 4. Extract user_id and action
$sessionId = $decoded['session_id'] ?? null;
$userId = (int) $decoded['user_id'] ?? null;
$action = $decoded['init_data']['action'] ?? 'unknown';

// 5. Verify session exists and is not revoked
if (!isset($activeSessions[$sessionId]) || $activeSessions[$sessionId]['revoked'] === false) {
// Session not found – reject
return response()->json(['error' => 'unauthorized'], 403);
}

// 6. Issue JWT after backend processing (e.g., DB lookup, rate limiting)
$jwt = $this->issueJwtForUser($userId, $sessionId);

// 7. Respond to Telegram with success
return response()->json(['ok' => true], 200);
}

/**
* Creates a signed JWT containing the user_id and expiration.
*/
private function issueJwtForUser(int $userId, string $sessionId): \String\JsonSerializable\Array\Object {
$payload = [
'sub' => $userId, // subject – the user ID
'iat' => time(),
'exp' => time() + 3600, // 1 hour validity
'iss' => env('APP_ID'), // issuer
];
return Hash::create(JWT_SECRET, json_encode($payload, JSON_UNESCAPED_SLASHES));
}

Key security points: - The HMAC key (INITDATA_SECRET) is kept separate from the JWT signing key (JWT_SECRET). If either leaks, the attack surface changes. - The JWT is short-lived (1 hour) and carries only the minimum claims needed for identification. - Sessions are tracked in memory here; in a real system use Redis or a database with TTL eviction. - Revocation is handled by setting 'revoked' => true on the session record when a token is presented.

Frontend: React Integration

The React side of the Mini App needs to: 1. Receive initData from the webhook (via window.data or a custom event). 2. Verify the signature locally (using the same secret) before trusting the user_id. 3. Store the verified user_id and exchange it for a JWT from your backend. 4. Use the JWT in the Authorization header for all subsequent API calls.

Here is a minimal React component that demonstrates the flow:

import React, { useState, useEffect } from 'react';
import { initDataVerifier } from './initdata-verifier';

// Shared secret – loaded from an environment variable in production
const INITDATA_SECRET = process.env.REACT_APP_INITDATA_SECRET || 'default-secret'; // change in prod

function TelegramMiniApp() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(false);

// Called when Telegram sends a new initData
const handleInitData = async (initDataBase64) => {
setLoading(true);
try {
// 1. Verify signature
const decoded = Buffer.from(initDataBase64, 'base64').toString('utf-8');
const sig = initDataVerifier.sign(decoded, INITDATA_SECRET);
if (sig !== decoded.substring(0, 32)) {
console.warn('Invalid initData signature');
return;
}

// 2. Extract user_id from initData
const parsed = JSON.parse(decoded);
const userId = parseInt(parsed.user_id, 10);
console.log(`Verified user_id: ${userId}`);

// 3. Exchange user_id for a JWT via the backend
const jwt = await fetch('/api/auth/jwt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Callback-Data': encodedCallback, // optional, see note below
},
body: JSON.stringify({ userId }),
});
const resp = await jwt.json();
if (resp.ok) {
const token = resp.token;
setSession({ userId, token });
}
} catch (err) {
console.error('Failed to verify initData:', err);
} finally {
setLoading(false);
}
};

// Simulate receiving initData from the webhook (in practice, listen to window.data)
useEffect(() => {
const handler = () => {
if (window.data && typeof window.data.initData === 'string') {
handleInitData(window.data.initData);
}
};
window.addEventListener('data', handler);
return () => window.removeEventListener('data', handler);
}, []);

return (
<div className="telegram-mini-app">
<h1>My Secure Mini App</h1>
{session ? (
<p>Logged in as user_id: {session.userId}</p>
<button onClick={() => window.close()}>Close</button>
) : (
<p>Waiting for initData…</p>
)}
</div>
);
}

export default TelegramMiniApp;

Handling Callback Queries

After the initial initData, the Mini App may receive inline keyboard interactions or button presses. These arrive as callback_query events. The standard pattern is:

1. Generate a unique callback query ID (e.g., bin2hex(random_bytes(7))) and store it alongside the session. 2. Send the callback with callback_data limited to 64 bytes (the platform limit). 3. On the backend, match the callback_query.id to the stored session ID and look up the associated user_id.

Example callback payload (≤64 bytes):

main_button=submit

This tells the backend which action was taken; the frontend doesn't need to know the user ID—the backend already has it.

Security Considerations and Edge Cases

Spoofing the user_id

Even though user_id lives inside initData, a malicious actor could still try to manipulate it. The solution is layered: - Backend verification: Only accept initData whose HMAC matches the secret. This proves the payload came from your server. - Session binding: Each session_id is tied to a specific user_id in the backend. If an attacker somehow obtains another user's user_id, they would also need to compromise the corresponding Telegram account (which requires knowing the phone number or OTP). - Rate limiting: Protect /api/auth/jwt with throttling to prevent brute-force enumeration of user IDs.

Replay Attacks

An attacker capturing a valid initData and resending it later could trigger duplicate actions. Mitigate this by: - Including a timestamp or nonce in initData and checking freshness on the backend. - Using short-lived JWTs (as shown above) so stale tokens become useless quickly.

Missing Signature

If initData arrives without a init_data_signature field, treat it as invalid immediately. Some legacy clients might omit it; always fail closed rather than attempt recovery.

Concurrent Sessions

Multiple threads could hold references to the same session_id. Ensure atomic operations (e.g., Redis SETNX) when creating or revoking sessions. In the PHP example, $activeSessions[$sessionId] is checked for existence; in production, use Redis with SET session_id user_id EX 3600 NX.

Error Handling

Return appropriate HTTP status codes: - 400 – missing or malformed initData. - 401 – invalid signature or unknown session. - 403 – session exists but has been revoked. - 429 – rate-limited (use exponential backoff on the client).

Always log failed attempts (without exposing secrets) for forensic analysis.

Putting It All Together

1. Frontend listens for window.data.initData, verifies the HMAC, extracts user_id, and exchanges it for a JWT via your PHP endpoint. 2. Backend validates the signature, looks up the session, issues a short-lived JWT, and returns success. 3. Subsequent API calls from the React app include the JWT in the Authorization header. The backend validates the JWT signature (if you sign it with the same secret) and extracts the sub claim to authorize the request.

This pattern keeps the authentication logic centralized in PHP while giving the React client a simple way to obtain credentials. It also allows you to rotate secrets independently—change INITDATA_SECRET without touching the JWT signing key.

Further Reading

For more details on the Telegram Bot API and how to implement @twa-dev/sdk, see the official documentation: https://botservice.biz/telegram-bot-api

BotCreator — studio that ships Telegram bots / Mini Apps.

---

By following this guide, you can confidently integrate a React-based Telegram Mini App with robust authentication, preventing spoofing attacks and ensuring that only authorized users can interact with your Mini App.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.