Verify Telegram Login Widget hash in PHP and bind telegram_id in Yii2

Telegram provides a Login Widget that lets websites authenticate users with their Telegram account. The widget returns a set of parameters via GET (or POST) that includes the user’s id, first name, last name, username, photo URL, and an auth_date timestamp. The most important part for security is the hash field, which is an HMAC‑SHA256 signature built from a secret derived from the bot token. If the hash is valid and the auth_date is recent, you can trust that the data really came from Telegram and safely associate the Telegram ID with a local user account.

This tutorial shows how to implement that verification in plain PHP, then integrate it into a Yii2 application. We will cover the exact algorithm Telegram uses, common pitfalls (clock skew, token leakage, replay attacks), and how to store the telegram_id securely. The guide assumes you already have a Yii2 project with a user table that can store a Telegram identifier.

## 1. How the Login Widget works When a user clicks the "Log in with Telegram" button, the widget opens a popup, asks for permission, and then redirects back to the URL you specified in the widget’s data-auth-url attribute. The redirect includes a query string with the following parameters: - id – Telegram user ID (integer) - first_name – optional - last_name – optional - username – optional - photo_url – optional - auth_date – Unix timestamp when the authentication request was made - hash – HMAC‑SHA256 of a data‑check string

The widget does not send a token or any session cookie; security relies entirely on the hash verification.

## 2. Verifying the hash Telegram’s specification says: 1. Create a secret key by hashing the bot token with SHA256. 2. Build a data‑check string consisting of all received fields except hash, sorted alphabetically, each on a new line in the format key=value. 3. Compute HMAC‑SHA256 of the data‑check string using the secret key. 4. Compare the result (hex‑lowercase) with the received hash.

If they match and auth_date is within an acceptable window (usually 10 minutes), the request is genuine.

2.1 Plain PHP verification function

/**
* Validate Telegram Login Widget data.
*
* @param array $params GET parameters (must contain hash, id, auth_date, …)
* @param string $botToken Your bot token from @BotFather
* @return bool True if hash is valid and auth_date is recent
*/
function verifyTelegramLogin(array $params, string $botToken): bool
{
// Extract hash and remove it from the data set
$hash = $params['hash'] ?? null;
unset($params['hash']);

// Build data‑check string
ksort($params);
$dataCheckLines = [];
foreach ($params as $key => $value) {
$dataCheckLines[] = "{$key}={$value}";
}
$dataCheckString = implode("\n", $dataCheckLines);

// Secret key = SHA256(botToken)
$secretKey = hash('sha256', $botToken, true);

// Compute HMAC‑SHA256
$computedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

// Compare hashes (use hash_equals to avoid timing attacks)
if (!hash_equals($hash, $computedHash)) {
return false;
}

// Check auth_date freshness (default 10 minutes)
$authDate = $params['auth_date'] ?? null;
if ($authDate === null) {
return false;
}
$now = time();
if (abs($now - (int)$authDate) > 600) { // 600 seconds = 10 minutes
return false;
}

return true;
}

Why we use hash_equals? Direct string comparison (==) can leak timing information that an attacker might exploit to guess the hash character by character. hash_equals runs in constant time.

Clock skew: Users’ devices may have slightly inaccurate clocks. A 10‑minute window is a common compromise; you can tighten it to 5 minutes if you accept occasional false negatives for users with badly synced clocks.

## 3. Integrating the verification into Yii2 Yii2 follows the MVC pattern. The simplest place to put the verification logic is a controller action that receives the callback from the widget. We’ll also create a small service method to keep the controller thin.

### 3.1 User table preparation Assume you have a user table with at least these columns: - id (primary key) - username (string) - email (string, nullable) - telegram_id (bigint, nullable, unique) - auth_key (string, for Yii2’s auto‑login) - password_hash (string, nullable if you only allow Telegram login)

Add a unique index on telegram_id to prevent two accounts from claiming the same Telegram ID.

### 3.2 Service class Create common/components/TelegramLoginService.php:

<?php
namespace common\components;

use Yii;
use common\models\User;

class TelegramLoginService
{
/**
* @param array $params GET parameters from Telegram widget
* @return User|null Authenticated user model or null on failure
*/
public static function login(array $params): ?User
{
$botToken = Yii::$app->params['telegramBotToken'];
if (!$botToken || !self::verifyTelegramLogin($params, $botToken)) {
Yii::warning('Telegram login verification failed', __METHOD__);
return null;
}

$telegramId = (int)$params['id'];
// Try to find existing user by telegram_id
$user = User::find()->where(['telegram_id' => $telegramId])->one();

if ($user === null) {
// No local account – create one or link to existing email/username if you want
$user = new User();
$user->telegram_id = $telegramId;
$user->username = $params['username'] ?? "tg{$telegramId}";
// Generate a secure random password (not used) and auth key
$user->setPassword(Yii::$app->security->generateRandomString());
$user->generateAuthKey();
$user->generateEmailVerificationToken();
// Optionally fill first/last name from Telegram
$user->first_name = $params['first_name'] ?? null;
$user->last_name = $params['last_name'] ?? null;
if (!$user->save()) {
Yii::error('Failed to save new Telegram user: ' . json_encode($user->errors), __METHOD__);
return null;
}
} else {
// Update profile info if it changed (optional)
$changed = false;
if ($user->username !== ($params['username'] ?? $user->username)) {
$user->username = $params['username'] ?? $user->username;
$changed = true;
}
if ($user->first_name !== ($params['first_name'] ?? $user->first_name)) {
$user->first_name = $params['first_name'] ?? $user->first_name;
$changed = true;
}
if ($user->last_name !== ($params['last_name'] ?? $user->last_name)) {
$user->last_name = $params['last_name'] ?? $user->last_name;
$changed = true;
}
if ($changed && !$user->save()) {
Yii::error('Failed to update Telegram user profile: ' . json_encode($user->errors), __METHOD__);
return null;
}
}

// Log the user in using Yii2’s built‑in login
if (!Yii::$app->user->login($user, 3600 * 24 * 30)) { // 30‑day remember
Yii::error('Yii2 login failed for telegram_id ' . $telegramId, __METHOD__);
return null;
}

return $user;
}

/**
* Internal hash verification – same as the plain PHP function above.
*/
private static function verifyTelegramLogin(array $params, string $botToken): bool
{
$hash = $params['hash'] ?? null;
unset($params['hash']);

ksort($params);
$lines = [];
foreach ($params as $k => $v) {
$lines[] = "{$k}={$v}";
}
$dataCheck = implode("\n", $lines);

$secret = hash('sha256', $botToken, true);
$computed = hash_hmac('sha256', $dataCheck, $secret);

if (!hash_equals($hash, $computed)) {
return false;
}

$authDate = $params['auth_date'] ?? null;
if ($authDate === null) {
return false;
}
$now = time();
return abs($now - (int)$authDate) <= 600;
}
}

Explanation of the service: - It reads the bot token from application parameters (params.php) – never hard‑coded. - It re‑uses the same verification logic as the standalone function, keeping the code DRY. - If the Telegram ID is not yet linked, it creates a new User record. You could also look up an existing account by email if you want to allow merging. - After creating or updating the record, it logs the user in via Yii::$app->user->login().

### 3.3 Controller action In frontend/controllers/SiteController.php (or a dedicated AuthController) add:

public function actionTelegramLogin()
{
$params = Yii::$app->request->get();
$user = \common\components\TelegramLoginService::login($params);

if ($user === null) {
// Show an error page or redirect back with a flash message
Yii::$app->session->setFlash('error', 'Telegram authentication failed. Please try again.');
return $this->goHome();
}

// Successful login – redirect to the intended page
return $this->goBack();
}

Make sure the action is accessible without authentication (i.e., not behind AccessControl that requires login).

### 3.4 Widget configuration In your view where you want the button, render the widget:

use yii\helpers\Html;

$botUsername = 'your_bot_username'; // without @
$authUrl = Url::to(['/site/telegram-login'], true); // absolute HTTPS URL

echo Html::tag('script', '', [
'src' => 'https://telegram.org/js/telegram-widget.js?22',
'async' => true,
'defer' => true,
]);

echo Html::tag('div', '', [
'class' => 'telegram-login-widget',
'data-size' => 'large',
'data-auth-url' => $authUrl,
'data-bot-username' => $botUsername,
'data-request-access' => 'write', // optional, if you need bot to send messages
]);

Important: The data-auth-url must be an absolute URL using HTTPS. Telegram will reject HTTP URLs for security reasons.

4. Production considerations

### 4.1 Token storage Never commit the bot token to version control. Store it in environment variables or a configuration file that is ignored by Git (e.g., params-local.php). In Yii2 you can do:

// params.php
return [
'telegramBotToken' => getenv('TELEGRAM_BOT_TOKEN') ?: 'placeholder-for-dev',
];

### 4.2 Replay attack mitigation The auth_date check prevents old requests from being reused, but a determined attacker could still capture a valid request and replay it within the 10‑minute window. To harden this: - Keep the window as low as your UX allows (e.g., 3 minutes). - Maintain a short‑term cache (Redis) of recently used auth_date values per id and reject duplicates.

### 4.3 Rate limiting Telegram does not enforce a strict rate limit on the Login Widget, but you should protect your endpoint from abuse. Use Yii2’s RateLimiter behavior:

public function behaviors()
{
return [
'rateLimiter' => [
'class' => \yii\filters\RateLimiter::class,
'enableRateLimitHeaders' => true,
],
];
}

### 4.4 Handling missing fields The widget may omit first_name, last_name, username, or photo_url if the user has hidden them. Your code should treat those as nullable and not rely on them for critical logic.

### 4.5 Linking to existing accounts If you already have users who sign up via email or other providers, you might want to allow them to link their Telegram account later. Implement a separate endpoint (e.g., /site/telegram-link) that verifies the hash, then updates the logged‑in user’s telegram_id if it is empty and not already taken.

### 4.6 Logging and monitoring Log verification failures (invalid hash, outdated auth_date) with context (IP address, user agent) to detect brute‑force or misconfigured widgets. Avoid logging the full hash or botToken.

## 5. Full example: putting it all together Below is a minimal but complete flow you can copy into a fresh Yii2 advanced template.

1. Add bot token to common/config/params.php:

return [
'telegramBotToken' => getenv('TELEGRAM_BOT_TOKEN'),
];

2. Create the service class as shown in section 3.2.

3. Add the controller action (section 3.3).

4. Insert the widget HTML into any view (section 3.4).

5. Run migrations to add the telegram_id column if you haven’t already:

// migrations/m200901_120000_add_telegram_id_to_user.php
public function safeUp()
{
$this->addColumn('{{%user}}', 'telegram_id', $this->bigInteger()->notNull()->unique());
}
public function safeDown()
{
$this->dropColumn('{{%user}}', 'telegram_id');
}

After migrating, test the flow: 1. Click the "Log in with Telegram" button. 2. Accept the permission dialog. 3. You should be redirected back to /site/telegram-login and logged in. 4. Check the user table – a new row (or updated row) should have the correct telegram_id.

6. Common pitfalls and how to avoid them

- Using == for hash comparison: Always use hash_equals. - Failing to sort parameters alphabetically: The spec requires alphabetical order; otherwise the hash will never match. - Sending the widget over HTTP: Telegram will silently ignore the request; ensure your site is served via HTTPS and the auth_url uses https://. - Ignoring auth_date: Without this check, an old valid widget response could be replayed indefinitely. - Storing the bot token in plain PHP files committed to Git: Leads to token theft and unauthorized bot control. - Assuming username is always present: Users can hide it; rely on id as the permanent identifier. - Not handling duplicate Telegram IDs: The unique index prevents race conditions, but you should still catch the exception and show a friendly message. - Over‑looking the need for answerCallbackQuery: This is only relevant for bots handling inline buttons, not for the Login Widget, so you can ignore it here.

## 7. Further reading For deeper insight into Telegram’s authentication mechanisms, see the official documentation: https://botservice.biz/telegram-bot-api

## 8. Closing note Implementing the Telegram Login Widget correctly gives you a frictionless way to onboard users who already have a Telegram account, while keeping the security guarantees that Telegram provides. By following the steps above—validating the HMAC‑SHA256 hash, checking the auth_date timestamp, and safely linking the telegram_id to your Yii2 user model—you can add this login method with confidence.

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.