When developers build a Telegram Mini App—whether it is a digital storefront, a seat booking system, or a lead capture workflow—they often fall into a critical security trap. The Telegram WebApp SDK exposes an object named window.Telegram.WebApp.initDataUnsafe. This object conveniently contains parsed information about the current Telegram user, including their numeric id, first_name, username, and language_code.
Because this object is readily available in JavaScript, frontend developers frequently inspect initDataUnsafe.user.id and send it directly in POST requests to their API endpoints (e.g., fetch('/api/orders', { body: JSON.stringify({ userId: user.id, cart }) })).
This architecture is fundamentally broken. Anyone opening your React application in a standard desktop web browser or inspecting network traffic via Chrome DevTools can alter the JavaScript runtime state or send raw HTTP requests directly to your backend. A malicious actor can replace their own Telegram user ID with the user ID of an administrator or another customer. If your backend relies on client-provided IDs, attackers can read private order histories, deplete user balances, or modify database records without authorization.
To make a Telegram Mini App secure, you must treat initDataUnsafe as completely untrusted UI-only metadata. Authentication must rely on the raw, unparsed string stored in window.Telegram.WebApp.initData. This raw string must be passed to your PHP backend, where its HMAC-SHA256 signature is verified using your secret Bot Token before any session, token, or order is generated.
Here is how to design a secure authentication workflow using a React frontend and a PHP backend.
The Authentication Architecture
Instead of passing individual user parameters to your backend endpoints, the authentication flow follows a strict two-step pattern:
1. Initialization & Exchange: Upon loading the React app, the frontend retrieves the raw window.Telegram.WebApp.initData query string. It sends this string inside an HTTP header or payload to a dedicated /api/auth/telegram endpoint. 2. HMAC Verification & Token Generation: The PHP backend parses the initData string, computes its HMAC-SHA256 signature using the Telegram Bot Token, checks that the authentication timestamp is recent, and extracts the verified user ID. Upon success, the backend generates a short-lived JSON Web Token (JWT) or session token and returns it to React. 3. Authenticated API Calls: All subsequent React application requests send this JWT in the Authorization: Bearer <token> header. Your API endpoints decode the JWT to identify the actor, completely ignoring any user ID passed in client request bodies.
+-----------------------+ +------------------------+
| React Telegram Client | | PHP Backend Server |
+-----------------------+ +------------------------+
| |
| 1. POST /api/auth/telegram |
| Header: X-Telegram-Init-Data |
|-------------------------------------------->|
| | -- Verify HMAC with Bot Token
| | -- Check auth_date age
| | -- Extract verified user_id
| 2. Returns { token: "JWT..." } | -- Generate JWT
|<--------------------------------------------|
|
| 3. POST /api/orders
| Header: Authorization: Bearer JWT
|-------------------------------------------->|
| | -- Decode JWT
| | -- Execute order for sub user_id
Step 1: React Frontend Auth Exchange
In your React application, create an authentication hook or context provider that handles the exchange of raw initData for an API access token.
Make sure your React component handles cases where the app is rendered outside Telegram (for local development) or when initData has not finished populating.
// src/hooks/useTelegramAuth.ts
import { useState, useEffect, useCallback } from 'react';
interface AuthState {
token: string | null;
isLoading: boolean;
error: string | null;
}
declare global {
interface Window {
Telegram?: {
WebApp?: {
initData: string;
ready: () => void;
};
};
}
}
export function useTelegramAuth() {
const [state, setState] = useState<AuthState>({
token: localStorage.getItem('app_jwt'),
isLoading: true,
error: null,
});
const authenticate = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
}
const initData = tg?.initData;
if (!initData) {
// In development outside Telegram, handle gracefully or require a mock setup
setState({
token: null,
isLoading: false,
error: 'Telegram Mini App environment not detected or initData is empty.',
});
return;
}
try {
const response = await fetch('/api/auth/telegram.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telegram-Init-Data': initData,
},
});
const responseData = await response.json();
if (!response.ok || !responseData.success) {
throw new Error(responseData.error || 'Authentication failed on server');
}
const { token } = responseData;
localStorage.setItem('app_jwt', token);
setState({
token,
isLoading: false,
error: null,
});
} catch (err: any) {
setState({
token: null,
isLoading: false,
error: err.message || 'Network error during authentication',
});
}
}, []);
useEffect(() => {
authenticate();
}, [authenticate]);
return { ...state, retryAuth: authenticate };
}
Never read Telegram.WebApp.initDataUnsafe inside this authentication hook to supply user details. The entire string must be sent raw so the backend can verify the cryptographic hash that Telegram generated.
Step 2: PHP HMAC-SHA256 Verification Engine
Telegram signs initData by creating an HMAC-SHA256 signature using a secret key. This secret key is not the raw Bot Token itself, but an HMAC-SHA256 digest of the Bot Token keyed with the static string WebAppData.
To perform verification correctly in PHP: 1. Parse the query string into key-value pairs. 2. Extract the hash parameter. 3. Exclude the hash parameter from the key set. 4. Sort the remaining parameters alphabetically by key in ascending order. 5. Format the sorted key-value pairs as string lines separated by \n in the format key=value (this forms the data_check_string). 6. Compute secret_key = hash_hmac('sha256', bot_token, 'WebAppData', true). 7. Compute calculated_hash = hash_hmac('sha256', data_check_string, secret_key). 8. Perform a timing-safe string comparison using hash_equals().
Here is a complete, standalone validator class in PHP:
<?php
// src/TelegramAuthValidator.php
class TelegramAuthValidator
{
private string $botToken;
private int $maxAuthAge;
public function __construct(string $botToken, int $maxAuthAge = 86400)
{
$this->botToken = $botToken;
$this->maxAuthAge = $maxAuthAge; // Default 24 hours threshold
}
/**
* Validates raw Telegram initData string and returns decoded user data array.
*
* @throws Exception if signature is invalid or data is expired.
*/
public function validate(string $initData): array
{
if (empty($initData)) {
throw new Exception('initData string is empty');
}
parse_str($initData, $data);
if (!isset($data['hash'])) {
throw new Exception('Hash parameter missing from initData');
}
$receivedHash = $data['hash'];
unset($data['hash']);
// Sort keys alphabetically
ksort($data);
// Build data_check_string
$dataCheckArr = [];
foreach ($data as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);
// Compute HMAC signature
$secretKey = hash_hmac('sha256', $this->botToken, 'WebAppData', true);
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
// Timing-safe comparison
if (!hash_equals($calculatedHash, $receivedHash)) {
throw new Exception('Invalid Telegram signature');
}
// Validate timestamp to prevent replay attacks
if (!isset($data['auth_date'])) {
throw new Exception('auth_date missing from payload');
}
$authDate = (int)$data['auth_date'];
if ((time() - $authDate) > $this->maxAuthAge) {
throw new Exception('Authentication payload has expired');
}
if (!isset($data['user'])) {
throw new Exception('User payload missing from initData');
}
$userData = json_decode($data['user'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Malformed user JSON in initData');
}
return [
'user' => $userData,
'auth_date' => $authDate,
'query_id' => $data['query_id'] ?? null,
];
}
}
Step 3: Authenticating API Routes and Issuing JWTs
Now, implement the /api/auth/telegram.php endpoint that receives the request header, runs the validator, generates a signed JWT, and returns it to the React application.
We will use a standard HMAC-SHA256 JWT builder in pure PHP to avoid unnecessary dependencies:
<?php
// public/api/auth/telegram.php
header('Content-Type: application/json');
require_once __DIR__ . '/../../src/TelegramAuthValidator.php';
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$jwtSecret = getenv('JWT_SECRET');
if (!$botToken || !$jwtSecret) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Server configuration error']);
exit;
}
// Retrieve initData from incoming header
$headers = getallheaders();
$initData = $headers['X-Telegram-Init-Data'] ?? $headers['x-telegram-init-data'] ?? '';
if (empty($initData)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing X-Telegram-Init-Data header']);
exit;
}
try {
$validator = new TelegramAuthValidator($botToken, 86400);
$validated = $validator->validate($initData);
$tgUser = $validated['user'];
$userId = (int)$tgUser['id'];
// Construct lightweight JWT payload
$issuedAt = time();
$expiresAt = $issuedAt + (86400 * 7); // 7 day access token
$header = base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payload = base64UrlEncode(json_encode([
'iss' => 'telegram-mini-app',
'sub' => $userId,
'first_name' => $tgUser['first_name'] ?? '',
'username' => $tgUser['username'] ?? '',
'iat' => $issuedAt,
'exp' => $expiresAt,
]));
$signature = base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $jwtSecret, true)
);
$jwt = "$header.$payload.$signature";
echo json_encode([
'success' => true,
'token' => $jwt,
'user' => [
'id' => $userId,
'first_name' => $tgUser['first_name'] ?? '',
],
]);
} catch (Exception $e) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
]);
}
function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
When React receives this token, it includes Authorization: Bearer <jwt> on every subsequent request to your backend endpoints (such as /api/orders.php). Your backend decodes the token using $jwtSecret, extracts the authenticated user ID from the sub claim, and performs database queries strictly against that verified ID.
Production Notes & Operational Security
1. Strict Expiry Limits on initData: Never set the maxAuthAge value indefinitely. While Telegram generated the signature, stale initData strings captured from network logs or local storage can be replayed by attackers. A threshold between 1 hour (3600s) and 24 hours (86400s) is ideal for initial authentication exchange. 2. URL Decoding Pitfalls: The parse_str() function in PHP automatically handles standard URL encoding. However, if your React app sends the header via customized proxy layers or WebServer rewrites (like NGINX), double URL encoding can occur. Ensure that user=%7B...%7D in initData is decoded exactly once before running json_decode(), or the cryptographic checksum will fail. 3. Bot Token Invalidation: The HMAC signature verification is directly tied to your Telegram Bot Token. If you revoke or regenerate your Bot Token via @BotFather, all previously signed initData payloads will immediately fail verification. Ensure your production secret management updates application environment variables synchronously. 4. Handling WebApp Ready Lifecycle: On the frontend, avoid invoking authentication network requests prior to calling window.Telegram.WebApp.ready(). Calling .ready() signals to the Telegram native container that WebApp handlers are mounted and ensures window.Telegram.WebApp.initData is populated in all mobile webview variants.
Need help building production-ready Telegram Mini Apps or backend infrastructure? BotCreator provides end-to-end development for secure Telegram bots and Mini Apps. Learn more about Telegram architecture in our technical guide at