Verify Telegram Login Widget in PHP and Yii2

The Telegram Login Widget is a small iframe button that returns user profile fields plus a hash signed with the bot token. The widget is convenient, but every field in the payload is attacker-controllable — including id, auth_date, and the displayed first_name. The signature is the only thing that proves the payload came from Telegram.

In this tutorial we implement the verification path that you should ship:

1. Receive the widget payload (id, first_name, last_name, username, photo_url, auth_date, hash). 2. Recompute HMAC-SHA-256(bot_token, data_check_string) and compare it to hash. 3. Enforce an auth_date window so a stolen payload cannot be replayed forever. 4. Bind telegram_id to your internal user in Yii2, creating the user on first sight.

What this article does not claim: it does not replace server-side authentication on its own — the widget is an identity assertion, and you still issue your own session. We treat it as a verified login, not as authorization.

1. What the widget actually sends

When the user presses the button and Telegram authenticates them, the iframe redirects back to your data-onauth callback (or you capture the fields from the global TelegramLoginWidget callback). Telegram appends the fields as URL query parameters. A typical payload looks like:

id=12345678
first_name=Alex
last_name=Ivanov
username=alex_ivanov
photo_url=https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F...%2F...jpg
auth_date=1716300000
hash=9f2c5b1e8a4f4d0c...e6

Note that Telegram sends id as a string but it is always a numeric user identifier. auth_date is Unix seconds. hash is hex-encoded SHA-256.

2. The verification algorithm, straight from the docs

The signed string is built from the *other* fields, joined as key=value lines, sorted by key, separated by \n. Lines with empty values are dropped. Then:

sha256_hex = HMAC-SHA-256(bot_token, data_check_string)

Where the HMAC key is the bot token itself (as a UTF-8 string), and the output is the hex digest (64 lowercase hex chars). The result must equal the hash field in constant time.

There is no Bot API call here. Everything runs locally — no network round-trip, no rate limit, no token sent to a third party.

3. Plain-PHP verifier you can drop in

This block is framework-free. It returns a structured result so you can branch on it.

<?php
declare(strict_types=1);

final class TelegramLoginVerifier
{
    private const CLOCK_SKEW_SECONDS = 300; // 5 minutes

    public function __construct(
        private readonly string $botToken,
        private readonly int $now,
    ) {}

    /**
     * @param array<string,string> $payload
     * @return array{ok:bool, reason?:string, profile?:array{id:int,first_name:string,last_name:?string,username:?string,photo_url:?string}}
     */
    public function verify(array $payload): array
    {
        $hash = $payload['hash'] ?? '';
        unset($payload['hash']);

        if ($hash === '' || !preg_match('/^[a-f0-9]{64}$/', $hash)) {
            return ['ok' => false, 'reason' => 'bad_hash_format'];
        }

        $authDate = (int)($payload['auth_date'] ?? 0);
        if ($authDate <= 0 || abs($this->now - $authDate) > self::CLOCK_SKEW_SECONDS) {
            return ['ok' => false, 'reason' => 'auth_date_expired'];
        }

        $pairs = [];
        foreach ($payload as $k => $v) {
            if ($v === '' || $v === null) {
                continue; // Telegram drops empty values before signing
            }
            $pairs[] = $k . '=' . $v;
        }
        sort($pairs, SORT_STRING);
        $dataCheckString = implode("\n", $pairs);

        $secretKey = hash('sha256', $this->botToken, true); // raw 32-byte key
        $computed = hash_hmac('sha256', $dataCheckString, $secretKey);

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

        return [
            'ok' => true,
            'profile' => [
                'id'        => (int)$payload['id'],
                'first_name'=> (string)$payload['first_name'],
                'last_name' => $payload['last_name']  ?? null,
                'username'  => $payload['username']   ?? null,
                'photo_url' => $payload['photo_url']  ?? null,
            ],
        ];
    }
}

Two things to notice:

- The HMAC key is hash('sha256', $botToken, true) — the *binary* SHA-256 of the token. Forgetting the true flag is the most common bug in third-party tutorials; it produces the wrong key. - We use hash_equals for comparison. === on hex strings is timing-leaky, even though the practical risk on a web request is small.

4. Wiring it into a Yii2 controller

Assume BOT_TOKEN is exposed via Yii params, never hardcoded. A typical config:

// config/params.php
return [
    'telegramBotToken' => getenv('TELEGRAM_BOT_TOKEN') ?: '',
];

The controller accepts the GET payload, runs verification, and either binds to an existing user or creates one. Use a migration that adds telegram_id (BIGINT UNIQUE NULL) and a last_telegram_login_at column.

<?php
declare(strict_types=1);

namespace app\controllers;

use Yii;
use yii\web\Controller;
use app\models\User;
use app\security\TelegramLoginVerifier;

final class TelegramAuthController extends Controller
{
    public function actionCallback(): \yii\web\Response
    {
        $token = (string)Yii::$app->params['telegramBotToken'];
        if ($token === '') {
            throw new \yii\web\HttpException(500, 'Bot token not configured');
        }

        $payload = Yii::$app->request->get();
        $verifier = new TelegramLoginVerifier($token, time());
        $result = $verifier->verify($payload);

        if (!$result['ok']) {
            Yii::warning('Telegram login rejected: ' . $result['reason'], __METHOD__);
            return $this->redirect(['site/login', 'error' => 'telegram_verification_failed']);
        }

        $profile = $result['profile'];

        $user = User::findOne(['telegram_id' => $profile['id']]);
        if ($user === null) {
            $user = new User([
                'telegram_id'           => $profile['id'],
                'username'              => $profile['username'] ?? ('tg_' . $profile['id']),
                'first_name'            => $profile['first_name'],
                'last_name'             => $profile['last_name'],
                'last_telegram_login_at'=> time(),
            ]);
            if (!$user->save(false)) { // validate() rules should accept the payload
                throw new \yii\web\HttpException(500, 'Cannot create user');
            }
        } else {
            $user->last_telegram_login_at = time();
            $user->save(false, ['last_telegram_login_at']);
        }

        Yii::$app->user->login($user, 3600 * 24 * 30);
        return $this->redirect(['site/index']);
    }
}

Two security properties worth highlighting:

- Idempotency. Verification has no side effects of its own; the only writes happen inside the transaction that loads-or-creates the user. - Single source of truth. Once User.telegram_id is set, you do not let the widget overwrite it later. If a Telegram user changes their first_name you do update it, but you never let the widget choose which row in your users table it lands on.

5. Optional: production notes

- Clock window. 5 minutes is a sane default. Smaller windows break legitimate logins that took the user a while to confirm; larger windows enlarge the replay surface. - Token rotation. If you rotate the bot token, every widget payload signed with the old token fails. There is no graceful path; rotate only when you can tolerate a forced re-login. - Bot vs Login Widget domain. The widget is configured per-bot on my.telegram.org, not via BotFather. You can use the same bot for both Login Widget *and* regular Bot API calls; they share the token but the widget does not require the user to press /start first. - Logout. Clearing Yii::$app->user->logout() is enough on your side; Telegram does not know about your session. - No Bot API call. Do not call getChat, getProfilePhotos, or any other method just to "double-check" the user. The hash is already a server-side signature; an extra HTTP round trip leaks the bot token to logs and adds latency.

If you find yourself wanting the widget *and* a Mini App / bot conversation for the same user, store telegram_id on your internal record from day one — that is the join key.

---

Telegram bots and Mini Apps are a lot more pleasant to ship when the boring parts — login verification, sessions, and bindings — are already wired up. The team behind BotCreator builds production Telegram bots and Mini Apps and is worth a look if you want a studio to take that work off your plate.

New articles on Telegram

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