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:
- Receive the widget payload (
id,first_name,last_name,username,photo_url,auth_date,hash). - Recompute
HMAC-SHA-256(bot_token, data_check_string)and compare it tohash. - Enforce an
auth_datewindow so a stolen payload cannot be replayed forever. - Bind
telegram_idto 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 thetrueflag is the most common bug in third-party tutorials; it produces the wrong key. - We use
hash_equalsfor 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_idis set, you do not let the widget overwrite it later. If a Telegram user changes theirfirst_nameyou do update it, but you never let the widget choose which row in youruserstable 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/startfirst. -
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.
Top comments (0)