To validate Telegram Mini App initData in PHP, rebuild a data-check-string from every received field except hash, compute HMAC-SHA-256 over it with a key derived from your bot token, compare the hex result to the received hash with hash_equals(), and then check that auth_date is recent enough for your application. Telegram’s Mini Apps documentation says you should only use data from initData on the bot’s server and only after it has been validated. initDataUnsafe on the client is not trustworthy.
This guide gives a working PHP function, explains each step, and covers the places where implementations usually go wrong: query-string parsing, argument order in hash_equals(), and the fact that Telegram does not define a mandatory expiry window.
As an Amazon Associate I earn from qualifying purchases.
What the client sends and what the server must do
Inside a Mini App, Telegram.WebApp.initData is a URL-encoded query string containing fields such as query_id, user (a JSON string), auth_date, and hash. Send that raw string unchanged to your backend over HTTPS, for example in a POST body or request header. Do not send the parsed initDataUnsafe object, and never rebuild the string from parsed values on the client.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →The server then performs two independent checks, and both must pass before any field is used:
#1 Best Overall
- Authenticity: the
hashmatches the HMAC you compute yourself. - Freshness:
auth_datefalls inside a window you chose.
The algorithm, step by step
1. Build the data-check-string
- Split the raw query string into
key=valuepairs and URL-decode the values. - Remove the
hashfield. For this bot-token HMAC method, every other received field stays in, includingsignatureif present. - Sort the remaining pairs alphabetically by key.
- Write each as
key=valueand join them with a single line feed ("n", byte0x0A). No spaces, no trailing newline.
Telegram’s example ordering is auth_date, query_id, user.
2. Derive the secret key
The secret is HMAC-SHA-256 where the message is your bot token and the key is the literal string WebAppData. This is easy to reverse by mistake. In PHP, hash_hmac() takes the data first and the key third, so the call is hash_hmac('sha256', $botToken, 'WebAppData', true). Request raw binary output (true) because this value is used as a key in the next step.
3. Compute the expected hash
Calculate HMAC-SHA-256 over the data-check-string using the derived secret, as a hexadecimal string (the default, non-binary output of hash_hmac()). This should equal the received hash.
Free tools Windows power users keep installed
One-click scans. No signup required.
4. Compare in constant time
hash_equals() is PHP’s timing-attack-safe string comparison. The manual describes it as checking whether two strings are equal without leaking information about the contents of known_string via execution time. The argument order matters: the known string (your computed digest) goes first and the user-supplied string (the received hash) goes second. Both arguments must be strings, otherwise PHP raises a TypeError, so type-check the input first.
5. Check auth_date
auth_date is a Unix timestamp marking when the data was produced. Telegram recommends checking it to avoid outdated data, but the official page does not state a maximum age, a clock-skew tolerance, or a replay store. Those are your decisions. See the dedicated section below.
A complete PHP implementation
The function below parses the query string by hand rather than with parse_str(), rejects duplicate keys, and fails closed. It requires PHP 8.0 or later for str_contains()-style conveniences and typed signatures; adapt as needed for older versions.
Rank #3
<?php
declare(strict_types=1);
/**
* Returns the validated fields (with 'user' decoded) or null on any failure.
*
* @param int $maxAgeSeconds Your application's freshness policy (not set by Telegram).
* @param int $futureSkew Tolerated clock drift for auth_date in the future.
*/
function validateTelegramInitData(
mixed $initData,
string $botToken,
int $maxAgeSeconds = 3600,
int $futureSkew = 30
): ?array {
if (!is_string($initData) || $initData === '' || $botToken === '') {
return null;
}
// 1. Parse manually so names are never rewritten and duplicates are visible.
$fields = [];
foreach (explode('&', $initData) as $pair) {
if ($pair === '') {
continue;
}
$pos = strpos($pair, '=');
if ($pos === false) {
return null; // malformed pair
}
$key = urldecode(substr($pair, 0, $pos));
$value = urldecode(substr($pair, $pos + 1));
if (array_key_exists($key, $fields)) {
return null; // duplicate key: reject
}
$fields[$key] = $value;
}
if (!isset($fields['hash'], $fields['auth_date'])) {
return null;
}
$receivedHash = $fields['hash'];
unset($fields['hash']);
// 2. Data-check-string: sorted by key, "key=value", joined by "n".
ksort($fields, SORT_STRING);
$lines = [];
foreach ($fields as $k => $v) {
$lines[] = $k . '=' . $v;
}
$dataCheckString = implode("n", $lines);
// 3. Secret key: HMAC-SHA256(message = bot token, key = "WebAppData"), raw bytes.
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
// 4. Expected hash as lowercase hex, compared in constant time.
$expected = hash_hmac('sha256', $dataCheckString, $secretKey);
if (!hash_equals($expected, $receivedHash)) { // known first, user-supplied second
return null;
}
// 5. Freshness (policy is yours).
if (!ctype_digit($fields['auth_date'])) {
return null;
}
$authDate = (int) $fields['auth_date'];
$now = time();
if ($authDate > $now + $futureSkew || $now - $authDate > $maxAgeSeconds) {
return null;
}
// 6. Only now decode nested JSON.
if (isset($fields['user'])) {
$user = json_decode($fields['user'], true);
if (!is_array($user) || !isset($user['id'])) {
return null;
}
$fields['user'] = $user;
}
return $fields;
}
Usage:
$raw = $_POST['initData'] ?? null; // or a request header
$data = validateTelegramInitData($raw, getenv('BOT_TOKEN') ?: '');
if ($data === null) {
http_response_code(401);
exit;
}
$telegramUserId = $data['user']['id'];
Return the same generic 401 for every failure so a caller cannot tell whether the signature or the timestamp was the problem. Log the reason server-side if you need it.
Why not just use parse_str()?
It is the shortcut most snippets use, and it often works, but PHP’s documented behavior makes it a poor fit for signature reconstruction:
- It URL-decodes values, which you need, but it also rewrites dots and spaces in parameter names to underscores. A field whose name contains either would be rebuilt under a different key.
- It is subject to the
max_input_varssetting, so unusually large input can be silently truncated. - Repeated keys collapse into a single array entry, hiding duplicates that you probably want to reject.
Any of these can make your reconstructed string differ from the one Telegram signed. Today’s standard fields have simple names, so the practical risk is lower than the principle suggests, but a manual split costs a few lines and removes the question. Whatever you choose, test the exact encodings your integration receives, including a user value with non-ASCII characters and escaped slashes.
Rank #4
Do not run json_decode(), htmlspecialchars(), trimming, or re-encoding on values before building the string. The signature covers the decoded text exactly as Telegram sent it.
Choosing an auth_date expiry window
Telegram defines auth_date as a Unix timestamp and advises checking it so outdated data cannot be accepted. The consulted official page gives no number of seconds. Any figure, including the 3600 default in the code above, is an application choice, not a Telegram requirement.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minutePoints to weigh when picking one:
- Use server time. Compare against
time()on your server, never a client-supplied clock. - Match the risk. A read-only profile page can tolerate a longer window than a payment, withdrawal, or admin action, which might justify a few minutes.
- Mind usability. A Mini App left open may keep presenting the same
initDatait received at launch. A tight window can reject legitimate users who return to an old session. A common pattern is to validate once, then issue your own short-lived session token and let that handle later requests. - Handle the future side. An
auth_datewell ahead of your clock is suspect. A small tolerance for drift is reasonable; the size is again yours to set.
Expiry is not replay protection
A valid signature within the window can be replayed by anyone who captures it, because nothing in the data changes per request. If your threat model needs one-time use, store a unique value (the hash, or query_id where present) until the window closes and refuse repeats. Telegram’s documentation does not require this, so treat it as an optional hardening measure for sensitive actions.
Best Value
- New
- Mint Condition
- Dispatch same day for order received before 12 noon
- Guaranteed packaging
- No quibbles returns
Bot-token HMAC versus third-party Ed25519 validation
Telegram documents two separate verification routes. They use different inputs and must not be mixed.
| Bot backend HMAC (this article) | Third-party Ed25519 | |
|---|---|---|
| Who verifies | The bot owner’s server, which holds the token | An external party that should not receive the bot token |
| Proof field | hash |
signature |
| Key material | Secret derived from the bot token with WebAppData |
Telegram’s public key plus the bot’s bot_id |
| Fields excluded from the check string | hash only |
Both hash and signature |
If you control the bot, use the HMAC route. Follow Telegram’s Mini Apps documentation for the exact Ed25519 string layout if you are the external verifier.
Common failure causes
- Swapped HMAC arguments. Using
WebAppDataas the message and the token as the key yields a different secret and every request fails. - Hex versus binary. The secret key must be raw binary (
true); the final digest must be hex. - Wrong separator. Use
"n"in double quotes, not the literal characters backslash-n in single quotes, and no trailing newline. - Hash left in the string, or
signaturewrongly removed in the HMAC route. - Wrong bot token. The token must belong to the bot that launched the Mini App; a different bot or a test-environment token gives a mismatch.
- Altered input. Middleware, frameworks, or JavaScript that decode, re-encode, or trim the string before it reaches PHP.
- Argument order in
hash_equals(). Reversing it still returns the right boolean but defeats the manual’s guidance on which side is the known string.
Operational hygiene
- Keep the bot token in an environment variable or secret store, never in client code, repositories, or logs. Anyone with the token can forge valid
initData. - Reject absent, empty, non-string, or oversized input before doing any work.
- Treat the validated
user.idas the identity key and look up your own account records from it; do not trust a user ID sent in a separate request field. - Rotate the token through BotFather if it leaks. Doing so invalidates the old derived secret.
Frequently Asked Questions
Is hash_equals timing safe in PHP?
Yes. The PHP manual describes it as comparing two strings without leaking information about the known string through execution time. Pass your computed digest first and the user-supplied hash second, and make sure both are strings.
Does Telegram say how long auth_date stays valid?
No. The official Mini Apps page defines auth_date as a Unix timestamp and recommends checking it to avoid outdated data, but it gives no maximum age. You choose the window.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




