Building a Telegram Mini App with React on the frontend and PHP on the backend often introduces a critical security flaw in session handling. When developers build an internal booking system, VIP store, or customer service desk inside Telegram, they frequently fall into the trap of reading window.Telegram.WebApp.initDataUnsafe.user.id on the client and passing that ID directly in JSON payloads or GET parameters to their backend API.
This architecture allows any user to open Chrome DevTools, inspect network requests, and change the user_id parameter to target another account. By spoofing the user ID, an attacker can read private bookings, drain account balances, or complete actions on behalf of other Telegram users. The initDataUnsafe object exists purely for UI rendering, such as displaying the user's first name while the app boots. It carries no cryptographic signature and must never be trusted for access control.
To secure your backend, your React frontend must extract the raw, unparsed string from window.Telegram.WebApp.initData and send it to your PHP API inside an HTTP authorization header. The backend then verifies the cryptographic hash using your Telegram bot token, enforces time-to-live restrictions on auth_date to prevent replay attacks, and issues a standard JSON Web Token (JWT) for subsequent authenticated API requests.
The Failure Mode: Why Client-Side Auth Collapses
When a Mini App opens inside Telegram, the parent client injects a global JavaScript object at window.Telegram.WebApp. This object contains two distinct properties holding user context: initDataUnsafe and initData.
The initDataUnsafe property contains a pre-parsed JavaScript object:
{
"query_id": "AAH...",
"user": {
"id": 987654321,
"first_name": "Alex",
"username": "alex_dev"
},
"auth_date": 1710000000,
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
If your React application relies on initDataUnsafe, an attacker can write a custom script or use standard HTTP interception tools to submit requests to your PHP endpoint containing modified payloads. Because initDataUnsafe is generated entirely in browser memory, changing user.id from 987654321 to 111111111 requires zero cryptographic effort.
In contrast, window.Telegram.WebApp.initData is an unparsed, raw URL-encoded query string directly created and signed by Telegram's servers:
query_id=AAH...&user=%7B%22id%22%3A987654321...%7D&auth_date=1710000000&hash=e3b0c4...
The hash parameter inside this raw string is an HMAC-SHA256 signature generated using a secret key derived from your bot token. Any modification to the user ID, timestamp, or query parameters inside this string invalidates the hash. Your PHP backend must receive this unaltered string, recalculate the hash using your secret bot token, and reject any request where the hashes do not match exactly.
Step 1: Sending Raw initData from React to Your PHP API
Your React application should never attempt to validate the Telegram hash locally. Front-end code exposes your secret bot token to the browser, which allows anyone to forge valid HMAC signatures.
Instead, create an authentication service in React that retrieves window.Telegram.WebApp.initData, transmits it to a dedicated /api/auth/telegram endpoint in PHP, and stores the resulting session JWT in memory or secure storage.
Here is a complete React custom hook and API utility that extracts the string, sends it securely, and manages authentication state:
// src/hooks/useTelegramAuth.ts
import { useState, useEffect, useCallback } from 'react';
interface AuthResponse {
token: string;
user: {
id: number;
firstName: string;
username?: string;
};
}
declare global {
interface Window {
Telegram?: {
WebApp?: {
initData: string;
ready: () => void;
expand: () => void;
};
};
}
}
export function useTelegramAuth(apiBaseUrl: string) {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const authenticate = useCallback(async () => {
setIsLoading(true);
setError(null);
const tg = window.Telegram?.WebApp;
if (!tg || !tg.initData) {
setError('Telegram WebApp environment not detected.');
setIsLoading(false);
return;
}
tg.ready();
try {
const response = await fetch(`${apiBaseUrl}/api/auth/telegram`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `TelegramInitData ${tg.initData}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Authentication failed with status ${response.status}`);
}
const data: AuthResponse = await response.json();
setToken(data.token);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown authentication error';
setError(message);
} finally {
setIsLoading(false);
}
}, [apiBaseUrl]);
useEffect(() => {
authenticate();
}, [authenticate]);
return { token, isLoading, error, refetch: authenticate };
}
Notice that tg.ready() is called, but network traffic is held back until the raw initData string is attached to the request. We pass the data in the Authorization header under a custom scheme (TelegramInitData <raw_string>) to avoid leaking authorization strings inside application logs or GET query parameters.
Step 2: Verifying the HMAC-SHA256 Signature in PHP
To verify the payload on your PHP backend, you must implement the precise cryptographic steps defined by the Telegram Bot API specification:
- Parse the incoming raw query string into key-value pairs.
- Extract the
hashparameter and remove it from the array of parameters to check. - Sort all remaining key-value pairs alphabetically by key.
- Format the sorted key-value pairs as
key=valuestrings joined by newline characters (\n). This forms thedata_check_string. - Compute a secret key using HMAC-SHA256:
HMAC-SHA256("WebAppData", bot_token). - Compute the HMAC-SHA256 hash of
data_check_stringusing the secret key derived in step 5. - Use timing-attack-safe string comparison (
hash_equals) to compare the calculated hash against the provided hash.
Below is a production-ready PHP class that executes this verification flow and validates the timestamp:
<?php
// src/Auth/TelegramInitDataValidator.php
namespace App\Auth;
class TelegramInitDataValidator
{
private string $botToken;
private int $maxAgeSeconds;
public function __construct(string $botToken, int $maxAgeSeconds = 86400)
{
$this->botToken = $botToken;
$this->maxAgeSeconds = $maxAgeSeconds;
}
/**
* Validates raw initData string. Returns parsed user array on success.
*
* @throws \InvalidArgumentException|\RuntimeException
*/
public function validate(string $rawInitData): array
{
if (empty($rawInitData)) {
throw new \InvalidArgumentException('InitData string cannot be empty.');
}
parse_str($rawInitData, $data);
if (!isset($data['hash'])) {
throw new \InvalidArgumentException('Missing hash parameter in initData.');
}
$providedHash = $data['hash'];
unset($data['hash']);
// Check timestamp freshness to prevent replay attacks
if (!isset($data['auth_date']) || !is_numeric($data['auth_date'])) {
throw new \InvalidArgumentException('Missing or invalid auth_date.');
}
$authDate = (int) $data['auth_date'];
if ((time() - $authDate) > $this->maxAgeSeconds) {
throw new \RuntimeException('Authentication data is stale (replay attack defense).');
}
// Sort parameters alphabetically
ksort($data);
// Build data_check_string
$dataCheckArr = [];
foreach ($data as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);
// Calculate cryptographic keys
$secretKey = hash_hmac('sha256', $this->botToken, 'WebAppData', true);
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);
if (!hash_equals($calculatedHash, $providedHash)) {
throw new \RuntimeException('HMAC verification failed. Data signature mismatch.');
}
// Parse user JSON payload safely
if (!isset($data['user'])) {
throw new \InvalidArgumentException('Missing user object in initData.');
}
$userData = json_decode($data['user'], true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($userData)) {
throw new \InvalidArgumentException('Malformed user JSON in initData.');
}
return $userData;
}
}
If an attacker alters the user field inside the query string, step 6 generates a completely different hash from providedHash. Step 7 rejects the payload via hash_equals, which runs in constant time to prevent timing side-channel attacks.
Step 3: Swapping Verified Credentials for a Backend JWT
Re-running HMAC verification on every API request is inefficient and forces your React app to store or re-fetch the raw initData payload repeatedly. The clean architectural pattern is to exchange a valid initData check once for an API session token (JWT).
Below is a PHP script handling the /api/auth/telegram route. It receives the Authorization header, runs the validator, generates a signed JWT using standard HMAC-SHA256, and returns it to the client:
<?php
// public/api/auth.php
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Auth\TelegramInitDataValidator;
header('Content-Type: application/json');
// Extract Authorization header
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!str_starts_with($authHeader, 'TelegramInitData ')) {
http_response_code(401);
echo json_encode(['error' => 'Missing or invalid Authorization header scheme.']);
exit;
}
$rawInitData = trim(substr($authHeader, 17));
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
http_response_code(500);
echo json_encode(['error' => 'Server misconfiguration: missing bot token.']);
exit;
}
$validator = new TelegramInitDataValidator($botToken, 86400); // 24-hour max age
try {
$user = $validator->validate($rawInitData);
// Bind the verified Telegram ID to application logic
$telegramId = (int) $user['id'];
$jwtSecret = getenv('JWT_SECRET') ?: 'fallback-secret-change-me';
// Build JWT payload
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload = json_encode([
'sub' => $telegramId,
'first_name' => $user['first_name'] ?? '',
'iat' => time(),
'exp' => time() + (3600 * 12) // 12-hour session
]);
$base64UrlHeader = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header));
$base64UrlPayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payload));
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $jwtSecret, true);
$base64UrlSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature));
$jwt = $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
http_response_code(200);
echo json_encode([
'token' => $jwt,
'user' => [
'id' => $telegramId,
'firstName' => $user['first_name'] ?? '',
'username' => $user['username'] ?? null
]
]);
} catch (\InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
} catch (\RuntimeException $e) {
http_response_code(403);
echo json_encode(['error' => $e->getMessage()]);
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Internal server error.']);
}
Once React receives this token, it stores it in state and attaches Authorization: Bearer <token> to subsequent requests to fetch private user data or perform writes.
Step 4: Making Authenticated API Calls in React
Here is how you consume the issued JWT within a React component. The component blocks user interactions (like submitting a form or clicking the Telegram MainButton) until authentication completes successfully.
// src/components/BookingView.tsx
import React, { useState } from 'react';
import { useTelegramAuth } from '../hooks/useTelegramAuth';
export const BookingView: React.FC = () => {
const { token, isLoading, error } = useTelegramAuth('https://api.yourdomain.com');
const [bookingStatus, setBookingStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const handleCreateBooking = async () => {
if (!token) return;
setIsSubmitting(true);
try {
const response = await fetch('https://api.yourdomain.com/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
slotId: 'slot_9912',
guests: 2
})
});
if (!response.ok) {
throw new Error('Booking request failed.');
}
const data = await response.json();
setBookingStatus(`Booking confirmed! Reference ID: ${data.bookingId}`);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Error submitting booking';
setBookingStatus(message);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="spinner">Authenticating with Telegram...</div>;
}
if (error) {
return <div className="error-banner">Access Denied: {error}</div>;
}
return (
<div className="booking-container">
<h2>Reserve Your VIP Slot</h2>
{bookingStatus && <p className="status-message">{bookingStatus}</p>}
<button
onClick={handleCreateBooking}
disabled={isSubmitting}
className="primary-btn"
>
{isSubmitting ? 'Processing...' : 'Confirm Booking'}
</button>
</div>
);
};
Production Security Notes
-
Strict Replay Protection: Setting an
auth_datecheck window is mandatory. If an attacker intercepts a legitimate rawinitDatapayload from network traffic, they can replay that string indefinitely unless your PHP backend rejects timestamps older than your threshold (e.g., 86400 seconds). For strict financial actions, reduce this window to 300 seconds. -
Telegram MainButton Timing: Do not bind click event handlers on the native
Telegram.WebApp.MainButtonto authenticated endpoints before your JWT state is established in React. Disable or hide the MainButton until the backend returns HTTP 200 with a signed token. -
Bot Token Isolation: Never leak the Telegram bot token into React build variables (
REACT_APP_orVITE_). The bot token is a master secret used to derive HMAC keys and execute API commands. It must reside exclusively in environment variables on your PHP backend. -
Database Identity Mapping: When creating user records on JWT issuance, map your primary keys against the
telegram_idexplicitly cast as a 64-bit integer (BIGINTin MySQL/PostgreSQL). Avoid standard 32-bit integers, as active Telegram user IDs routinely exceed 2^31 - 1.
Need production-grade architecture for your next launch? Work with BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)