DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Validate Telegram Mini App initData in PHP: HMAC-SHA-256, timing-safe compare, and auth_date expiry

What this tutorial covers

When a Telegram Mini App opens, the client passes an initData string into the WebApp SDK. Your backend should treat that string as untrusted input: a forged hash is cheap to build if you forget any step. The official recipe, published under the Telegram docs, is:

  1. Parse initData as a query string and drop hash.
  2. Sort the remaining pairs by key.
  3. Join them with \n into a data_check_string.
  4. Compute HMAC-SHA-256(data_check_string, secret_key) where secret_key = HMAC-SHA-256("WebAppData", bot_token).
  5. Compare the hex digest against hash using a timing-safe comparison.
  6. Verify that auth_date is within an acceptable window.

I will walk through a single validateInitData function that does exactly that, then show how to wire it into a controller. I do not cover signature replay across different bots, the receipt API, or the Login Widget — those are different surfaces with different rules.

Parsing initData

initData is delivered as a percent-encoded query string. parse_str works, but it converts dots in keys into underscores, which breaks keys like tg_start_param on PHP 8+ unless parse_str runs without the legacy behaviour. Easiest path: use urldecode after splitting on &.

function parseInitData(string $raw): array
{
    $pairs = [];
    foreach (explode('&', $raw) as $pair) {
        if ($pair === '') {
            continue;
        }
        $kv = explode('=', $pair, 2);
        if (count($kv) !== 2) {
            continue;
        }
        $pairs[urldecode($kv[0])] = urldecode($kv[1]);
    }
    return $pairs;
}
Enter fullscreen mode Exit fullscreen mode

This gives you a flat array. hash is present at the top level alongside fields such as auth_date, query_id, user, start_param, and chat_type. Nested fields like user arrive as JSON; we leave them encoded until we are ready to decode, because the HMAC is computed over the raw values.

Building data_check_string

Telegram's spec is explicit: every remaining pair goes in key=value form, sorted by key, joined by \n. Keep the values exactly as they arrived after URL-decoding — no JSON re-encoding.

function buildCheckString(array $params): string
{
    unset($params['hash']);
    ksort($params, SORT_STRING);
    $lines = [];
    foreach ($params as $k => $v) {
        $lines[] = $k . '=' . $v;
    }
    return implode("\n", $lines);
}
Enter fullscreen mode Exit fullscreen mode

A subtle bug source is ksort flags. SORT_STRING matches the official examples. Locale-aware sorts (the default in some PHP builds under specific setlocale calls) will reorder letters and break the hash.

Deriving the secret key

The HMAC key is not the bot token itself. It is HMAC-SHA-256("WebAppData", bot_token), returned as raw bytes. Telegram's docs and the WebApp source both use this construction; if you skip the nested HMAC, every signature check fails.

function webAppSecretKey(string $botToken): string
{
    return hash_hmac('sha256', "WebAppData", $botToken, true);
}
Enter fullscreen mode Exit fullscreen mode

Pass true as the last argument to get the raw 32-byte digest, then feed that into hash_hmac again for the data check string.

Timing-safe comparison and auth_date TTL

Plain === between two hex strings leaks the prefix length through timing differences. Use hash_equals:

function verifyInitData(string $raw, string $botToken, int $maxAgeSeconds = 86400): array
{
    $params = parseInitData($raw);
    if (!isset($params['hash'], $params['auth_date'])) {
        return ['ok' => false, 'reason' => 'missing_fields'];
    }

    $checkString = buildCheckString($params);
    $secretKey   = webAppSecretKey($botToken);
    $computed    = hash_hmac('sha256', $checkString, $secretKey);

    if (!hash_equals($params['hash'], $computed)) {
        return ['ok' => false, 'reason' => 'bad_signature'];
    }

    $authDate = (int) $params['auth_date'];
    if ($authDate <= 0 || $authDate > time() + 60) {
        return ['ok' => false, 'reason' => 'auth_date_in_future'];
    }
    if (time() - $authDate > $maxAgeSeconds) {
        return ['ok' => false, 'reason' => 'auth_date_expired'];
    }

    $user = isset($params['user']) ? json_decode($params['user'], true) : null;

    return [
        'ok'      => true,
        'user_id' => is_array($user) && isset($user['id']) ? (int) $user['id'] : null,
        'user'    => $user,
        'params'  => $params,
    ];
}
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices:

  • I add a small +60 second skew to the upper bound. Clock drift between the Telegram client and your server is small but real; rejecting a freshly minted initData because it is "1.2 seconds in the future" is the kind of bug that wastes an afternoon.
  • The default TTL is 24 hours. Shorter is safer (15 minutes is a common production value), longer is friendlier for offline SPA sessions. Pick what your threat model tolerates.
  • json_decode on the user field is done after signature verification. If the JSON is malformed you still know the request was authentic; you just cannot trust the nested fields.

Wiring it into a controller

The Mini App client posts initData either raw (in the standard WebApp SDK) or as a form field. Treat the raw value as the source of truth. Read the bot token from configuration, never from request input.

$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
    http_response_code(500);
    exit('bot token not configured');
}

$raw = $_POST['initData'] ?? '';
if ($raw === '') {
    http_response_code(400);
    exit('initData missing');
}

$result = verifyInitData($raw, $token, maxAgeSeconds: 900);

if (!$result['ok']) {
    http_response_code(401);
    exit('invalid: ' . $result['reason']);
}

// result['user_id'] is now safe to bind to a session or DB row
Enter fullscreen mode Exit fullscreen mode

Two hardening notes that are easy to forget:

  • Cache nothing based on hash alone. A signed initData proves the client talked to your bot, but it does not authorise a specific user across requests. Bind to (bot_id, user_id) server-side.
  • Do not log full initData. It contains the user's id, username, and sometimes start_param values that act as one-time tokens. Redact before logging.

Production notes (optional)

These are things I would add before this code faces real traffic; they are not strictly required for the validation step itself.

  • Rate-limit the endpoint. An attacker who cannot forge a signature can still spam your endpoint with garbage. Token-bucket by IP and by tg_id once you have one.
  • Validate the nested user shape. Even after a valid signature, assert that user.id is a positive integer and that user.is_bot is false if you only serve humans.
  • Use a stable clock source. time() is fine for the TTL check, but if you fan out validation across multiple PHP-FPM workers behind a load balancer with skewed NTP, consider reading auth_date against the same monotonic source you use everywhere else.
  • Watch for can_send_after and chat_instance. If your Mini App is launched from an inline button inside a chat, those fields appear in initData. Your check string still works — they are just extra keys that get sorted and hashed like any other — but you may want to assert their presence.

If you want a reference implementation that also exposes a typed DTO and integrates with the Login Widget hash, BotCreator ships Telegram bots and Mini Apps end-to-end. The Telegram Bot API reference is a good companion while you build:

Top comments (0)