Telegram Mini Apps run inside a WebView within the Telegram client, communicating with your backend API through HTTP requests. When a user opens your Mini App, Telegram exposes the window.Telegram.WebApp.initData and window.Telegram.WebApp.initDataUnsafe JavaScript objects containing user identity details like user ID, username, and language preferences.
Treating initDataUnsafe as an authentication token is a major security flaw. Because initDataUnsafe is parsed directly on the client side, any user can open Chrome DevTools, modify the JSON values in memory, and send an HTTP request to your PHP backend pretending to be another user or an administrator. If your backend relies on the user ID inside initDataUnsafe to grant session access or read database records, an attacker can access arbitrary user accounts by sending spoofed request payloads.
To prevent identity spoofing, Telegram signs the query parameters contained in window.Telegram.WebApp.initData using a hash derived from your Telegram Bot Token. Your backend must parse this raw URL-encoded string, recalculate the HMAC-SHA256 signature on the server, compare it in a timing-safe manner, and enforce an expiration window on the auth_date timestamp.
The Anatomy of initData and Spoofing Risks
When your frontend code sends authentication data to your backend, it must transmit the raw, untouched string from window.Telegram.WebApp.initData. The raw string looks like a standard URL query string:
user=%7B%22id%22%3A123456789%2C%22first_name%22%3A%22Jane%22%2C%22username%22%3A%22janedoe%22%7D&auth_date=1710000000&query_id=AAFx...&hash=d78a9c... A naive front-end integration reads initDataUnsafe.user.id and sends it as a JSON payload to the API server. Here is an example of what not to do on the frontend and backend:
// BAD: Frontend sending client-parsed data const initDataUnsafe = window.Telegram.WebApp.initDataUnsafe; fetch('/api/checkout.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: initDataUnsafe.user.id, cartId: 42 }) }); <?php // BAD: PHP Backend trusting client-supplied user ID directly $request = json_decode(file_get_contents('php://input'), true); $userId = $request['userId'] ?? null; // Attacker can pass any userId here, granting full access to that account's cart! $cart = getCartByUserId($userId); If your server accepts unverified client payloads, anyone can execute fetch('/api/checkout.php', { body: JSON.stringify({ userId: 1 }) }) to perform actions on behalf of account ID 1. To resolve this, the client must transmit the full, unparsed initData string in the Authorization HTTP header or request payload, allowing the PHP backend to verify its integrity before reading any fields.
Parsing the Query String and Reconstructing the Data-Check String
Telegram's signature algorithm requires reconstructing a deterministic key-value string called the data_check_string. The verification algorithm follows these exact steps:
- Parse the raw URL query string into an associative array of key-value pairs.
- Extract the
hashparameter from the array. This value is the client-provided signature. - Remove the
hashparameter from the key-value array so it is not included in the verification payload. - Sort the remaining parameters alphabetically by key using
ksort(). - Format each parameter as
key=valueand join them with a newline character (\n). Note that values must remain unescaped URL values exactly as received before query string decoding. - Compute an HMAC-SHA256 signature of the string constant
WebAppDatausing your secret Telegram Bot Token as the key. This yields the signature key. - Compute an HMAC-SHA256 signature of the
data_check_stringusing the signature key derived in step 6. - Compare the calculated hex hash against the original
hashparameter using a timing-safe string comparison.
One common pitfall in PHP is using urldecode() or parse_str() improperly before sorting. Standard parse_str() decodes percent-encoded characters, which can alter complex JSON strings inside parameters like user. If the characters in the reconstructed parameter string do not match the byte sequence generated by Telegram, signature validation will fail.
Here is a pure PHP implementation function that correctly extracts parameters and formats the data_check_string:
<?php declare(strict_types=1); function buildDataCheckString(string $rawInitData): array { $pairs = explode('&', $rawInitData); $data = []; $providedHash = ''; foreach ($pairs as $pair) { if ($pair === '') { continue; } $parts = explode('=', $pair, 2); if (count($parts) !== 2) { continue; } $key = urldecode($parts[0]); $value = urldecode($parts[1]); if ($key === 'hash') { $providedHash = $value; } else { $data[$key] = $value; } } if (empty($providedHash)) { throw new InvalidArgumentException('Missing hash parameter in initData.'); } ksort($data); $checkItems = []; foreach ($data as $key => $value) { $checkItems[] = $key . '=' . $value; } $dataCheckString = implode(" ", $checkItems); return [ 'data_check_string' => $dataCheckString, 'hash' => $providedHash, 'parsed_data' => $data, ]; } Implementing Complete HMAC Validation and Timestamp Expiration
Verifying the HMAC hash only guarantees that the payload was generated by Telegram for your specific bot. It does not prevent replay attacks. If an attacker intercepts a legitimate user's raw initData string, they could reuse that string indefinitely unless your backend rejects stale payloads.
Telegram includes an auth_date parameter in the payload, representing the Unix timestamp when the initData string was generated. Your backend must verify that auth_date is present, represents a valid numeric timestamp, and falls within an acceptable time window (typically 86,400 seconds or 24 hours).
Additionally, string comparisons between the calculated hash and the provided hash must use hash_equals(). Using the standard binary equality operator (===) exposes your code to timing side-channel attacks, where an attacker can deduce hash characters byte by byte based on response latency variations.
Here is a complete, reusable PHP validation class that handles HMAC generation, timestamp freshness checks, and user payload extraction:
<?php declare(strict_types=1); 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 and returns parsed user payload if authentic. * * @throws Exception if validation fails */ public function validate(string $rawInitData): array { if (empty($rawInitData)) { throw new InvalidArgumentException('Empty initData payload provided.'); } $parsed = buildDataCheckString($rawInitData); $dataCheckString = $parsed['data_check_string']; $providedHash = $parsed['hash']; $data = $parsed['parsed_data']; // Step 1: Check auth_date parameter if (!isset($data['auth_date']) || !is_numeric($data['auth_date'])) { throw new RuntimeException('Missing or invalid auth_date in initData.'); } $authDate = (int)$data['auth_date']; $currentTime = time(); if (($currentTime - $authDate) > $this->maxAgeSeconds) { throw new RuntimeException('Payload authentication timestamp has expired.'); } if ($authDate > ($currentTime + 60)) { throw new RuntimeException('Payload timestamp is in the future (clock skew detected).'); } // Step 2: Compute secret key = HMAC-SHA256("WebAppData", bot_token) $secretKey = hash_hmac('sha256', $this->botToken, 'WebAppData', true); // Step 3: Compute data hash = HMAC-SHA256(data_check_string, secret_key) $calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey); // Step 4: Timing-safe comparison if (!hash_equals($calculatedHash, $providedHash)) { throw new SecurityException('Invalid initData signature hash mismatch.'); } // Step 5: Parse embedded JSON user object if present if (isset($data['user'])) { $userData = json_decode($data['user'], true); if (json_last_error() === JSON_ERROR_NONE) { $data['user'] = $userData; } } return $data; } } Integrating the Validator into a Production PHP API Endpoint
In a production application, the frontend client fetches the raw window.Telegram.WebApp.initData and passes it via the Authorization header using a tma prefix (standard Telegram Mini App convention) or a custom header like X-Telegram-Init-Data.
Your PHP backend endpoint receives the request, extracts the header, loads the secret bot token from an environment variable via getenv(), passes the string to the validator, and proceeds with the business logic only if authentication succeeds.
Here is an operational endpoint script showing the complete execution flow:
<?php declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); require_once __DIR__ . '/TelegramInitDataValidator.php'; // Load bot token from environment variable $botToken = getenv('TELEGRAM_BOT_TOKEN'); if (!$botToken) { http_response_code(500); echo json_encode(['ok' => false, 'error' => 'Server misconfiguration: bot token missing']); exit; } // Retrieve authorization header $headers = getallheaders(); $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? ''; $rawInitData = ''; if (str_starts_with($authHeader, 'tma ')) { $rawInitData = substr($authHeader, 4); } elseif (isset($_SERVER['HTTP_X_TELEGRAM_INIT_DATA'])) { $rawInitData = $_SERVER['HTTP_X_TELEGRAM_INIT_DATA']; } if (empty($rawInitData)) { http_response_code(401); echo json_encode(['ok' => false, 'error' => 'Missing authorization header containing initData']); exit; } $validator = new TelegramInitDataValidator($botToken, 86400); try { $validatedPayload = $validator->validate($rawInitData); // Identity is cryptographically proven $telegramUser = $validatedPayload['user'] ?? null; if (!$telegramUser || !isset($telegramUser['id'])) { http_response_code(400); echo json_encode(['ok' => false, 'error' => 'User object missing from validated payload']); exit; } $telegramUserId = (int)$telegramUser['id']; // Safe to perform application logic // e.g., create session, query database, or return secure state echo json_encode([ 'ok' => true, 'message' => 'Authenticated successfully', 'user' => [ 'id' => $telegramUserId, 'username' => $telegramUser['username'] ?? '', 'first_name' => $telegramUser['first_name'] ?? '' ] ]); } catch (SecurityException | RuntimeException $e) { http_response_code(401); echo json_encode(['ok' => false, 'error' => 'Authentication failed: ' . $e->getMessage()]); } catch (Exception $e) { http_response_code(400); echo json_encode(['ok' => false, 'error' => 'Malformed payload: ' . $e->getMessage()]); } Production Considerations: Clock Skew, Replay Attacks, and Session Management
When deploying initData verification in high-traffic applications, several edge cases require special handling:
Server Clock Synchronization: Ensure your PHP application server runs
NTP(Network Time Protocol). If your server clock drifts behind Telegram's servers, legitimate requests will fail theauth_date > currentTimecheck. Allowing a small 60-second forward tolerance accounts for standard network jitter and minor clock discrepancies.Shortening the Max-Age Window: While 24 hours (
86400seconds) is common for initial prototyping, production financial or data-sensitive Mini Apps should enforce tighter lifetimes (e.g.,3600seconds / 1 hour). If a user leaves the Mini App open for extended periods, the frontend should prompt the client to re-initialize or issue a refreshed backend session token (such as a JWT or secure PHP session cookie) upon initial validation.Avoiding Client Readiness Race Conditions: In single-page applications (React, Vue, vanilla JS), do not invoke backend API endpoints until
window.Telegram.WebApp.ready()has been called andwindow.Telegram.WebApp.initDatacontains a non-empty string. Calling API endpoints before the Telegram SDK finishes initialization results in premature 401 unauthorized errors.
By keeping your bot token safe in environment variables, rejecting stale timestamps, and running timing-safe HMAC validation on every incoming request, you shield your PHP application from identity impersonation attacks.
If you need assistance constructing production-ready infrastructure or custom webhooks for your Telegram ecosystem, contact BotCreator — studio that ships Telegram bots / Mini Apps.


