Authenticate Telegram Mini App initData Signatures in Native PHP

When building a Telegram Mini App, the frontend interface runs inside an embedded web view. To identify the user and grant access to your backend API, the Telegram client provides a signed initialization string available via window.Telegram.WebApp.initData. Because this data originates on the client device, it can be manipulated, intercepted, or forged.

In this guide, we will implement server-side verification of Telegram Mini App initData payloads in plain PHP 8.x. We will build an explicit signature verification pipeline using HMAC-SHA-256, execute timing-safe comparisons to prevent timing attacks, enforce replay protection with auth_date expiry limits, and safely decode embedded JSON objects.

We will NOT cover frontend SDK setup, custom JWT generation, or framework-specific wrappers. This tutorial focuses strictly on native PHP cryptographic verification.

---

Understanding the Telegram Verification Protocol

Telegram signs the query string parameters using a two-stage HMAC-SHA-256 hashing scheme:

1. Secret Key Generation: A secret key is generated by taking the SHA-256 HMAC of your raw Telegram Bot Token using the constant string WebAppData as the key. 2. Data Check String Assembly: All incoming key-value pairs from initData (excluding the hash parameter itself) are decoded, sorted alphabetically by key, and formatted into newline-separated string pairs (key=value ). 3. Signature Hash Calculation: The assembled data check string is hashed via HMAC-SHA-256 using the derived secret key. 4. Comparison: The resulting hex string is compared against the hash parameter provided in the initData payload.

If the calculated hash matches the client-supplied hash, the request is authentic and originated from Telegram.

---

Step 1: Parsing the Raw Query String

The raw string from window.Telegram.WebApp.initData looks like a standard URL query string:

query_id=AAHdACwAAAAAAI0ALJ_...&user=%7B%22id%22%3A123456789%2C%22first_name%22%3A%22Alice%22%7D&auth_date=1710000000&hash=d7f3e...

While PHP provides parse_str(), using it directly can be risky because it alters array structures when key names contain dots or spaces and performs automatic string conversions. Instead, manually parsing the raw query string guarantees exact key-value mapping without unneeded type coercion.

Here is a helper function to parse raw query string key-value pairs cleanly:

<?php

declare(strict_types=1);

/**
* Parses raw initData query string into an associative array without standard parse_str side effects.
*
* @param string $rawInitData
* @return array<string, string>
*/
function parseInitDataQuery(string $rawInitData): array
{
if (empty($rawInitData)) {
return [];
}

$pairs = explode('&', $rawInitData);
$data = [];

foreach ($pairs as $pair) {
if ($pair === '') {
continue;
}

$parts = explode('=', $pair, 2);
$key = urldecode($parts[0]);
$value = isset($parts[1]) ? urldecode($parts[1]) : '';

$data[$key] = $value;
}

return $data;
}

By explicitly URL-decoding key names and values during pair splitting, we avoid nested array mangling and retain the exact strings supplied by the Telegram client.

---

Step 2: Building the Signature Validator Engine

To verify the payload, we must extract the hash field, sort the remaining fields in alphabetical order using ksort(), construct the key=value check string, compute the expected HMAC, and compare it against the original hash.

To protect against timing attacks, never compare cryptographic hashes with standard string operators (=== or ==). Standard string equality checks return false as soon as the first non-matching byte is found, exposing subtle processing time differences that an attacker can measure. Always use PHP's native hash_equals() function.

<?php

declare(strict_types=1);

/**
* Validates Telegram Mini App initData signature and freshness.
*
* @param string $rawInitData The raw initData string from window.Telegram.WebApp.initData
* @param string $botToken The Telegram Bot Token from environment/config
* @param int $maxAgeSeconds Maximum acceptable age of auth_date in seconds (default 86400 = 24h)
* @return array{valid: bool, reason: ?string, payload: array<string, mixed>}
*/
function validateTelegramInitData(string $rawInitData, string $botToken, int $maxAgeSeconds = 86400): array
{
$data = parseInitDataQuery($rawInitData);

if (!isset($data['hash']) || $data['hash'] === '') {
return ['valid' => false, 'reason' => 'Missing hash parameter', 'payload' => []];
}

if (!isset($data['auth_date']) || !ctype_digit($data['auth_date'])) {
return ['valid' => false, 'reason' => 'Missing or invalid auth_date', 'payload' => []];
}

$providedHash = $data['hash'];
unset($data['hash']);

// Sort parameters alphabetically by key
ksort($data);

// Build the data check string
$dataCheckArr = [];
foreach ($data as $key => $value) {
$dataCheckArr[] = $key . '=' . $value;
}
$dataCheckString = implode("
", $dataCheckArr);

// Compute secret key: HMAC-SHA-256("WebAppData", bot_token)
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);

// Compute expected hash: HMAC-SHA-256(dataCheckString, secretKey)
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

// Timing-safe comparison
if (!hash_equals($calculatedHash, $providedHash)) {
return ['valid' => false, 'reason' => 'Signature mismatch', 'payload' => []];
}

// Check auth_date expiration to prevent replay attacks
$authTimestamp = (int) $data['auth_date'];
$currentTimestamp = time();

if (($currentTimestamp - $authTimestamp) > $maxAgeSeconds) {
return ['valid' => false, 'reason' => 'Authentication date expired', 'payload' => []];
}

if ($authTimestamp > ($currentTimestamp + 60)) {
return ['valid' => false, 'reason' => 'Authentication date is in the future', 'payload' => []];
}

return ['valid' => true, 'reason' => null, 'payload' => $data];
}

---

Step 3: Parsing User Data JSON and Handling Edge Cases

Once signature verification passes, the user field in $payload contains a JSON string representing user profile information (such as id, first_name, username, language_code, etc.).

Do not assume json_decode() will always succeed without error checking. Always call json_last_error() or use JSON_THROW_ON_ERROR to handle potential parsing issues gracefully.

<?php

declare(strict_types=1);

$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
http_response_code(500);
echo json_encode(['error' => 'Server configuration error']);
exit;
}

$rawInitData = $_POST['init_data'] ?? $_SERVER['HTTP_X_TELEGRAM_INIT_DATA'] ?? '';

$validationResult = validateTelegramInitData($rawInitData, $botToken, 86400);

if (!$validationResult['valid']) {
http_response_code(401);
echo json_encode([
'ok' => false,
'error' => 'Unauthorized',
'details' => $validationResult['reason']
]);
exit;
}

$payload = $validationResult['payload'];
$userData = [];

if (isset($payload['user'])) {
$userData = json_decode($payload['user'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid user JSON object']);
exit;
}
}

// Validation passed, process authenticated user session
$telegramUserId = $userData['id'] ?? null;

http_response_code(200);
echo json_encode([
'ok' => true,
'user_id' => $telegramUserId,
'first_name' => $userData['first_name'] ?? '',
'authenticated_at' => $payload['auth_date']
]);

---

Production Considerations & Pitfalls

1. Never hardcode bot tokens: Always retrieve bot tokens using getenv(), $_ENV, or an immutable config array. Exposing your token allows malicious actors to issue administrative bot commands or spoof signatures. 2. Replay Attack Limits: Keep maxAgeSeconds tight. While 24 hours (86400s) is common for initial prototyping, production API setups that convert initData into short-lived session tokens should reduce this window to 300–1800 seconds. 3. Double URL Encoding: Ensure your frontend sends the exact un-decoded initData payload directly from window.Telegram.WebApp.initData. Decoding the string on the client side before sending it to PHP will alter key-value formatting and break the signature check. 4. Time Sync: Ensure your backend server synchronizes system clocks via Network Time Protocol (NTP). Severe server time drift can cause legitimate request signatures to fail the auth_date check.

When you need production-grade Telegram integration or bespoke Mini App architectures built to scale, explore BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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