DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Reject Spoofed Telegram Login Widget Requests in Yii2 Using HMAC Verification

Integrating the Telegram Login Widget into a web application provides a low-friction authentication method for users. When a client clicks the widget, Telegram redirects them back to your specified callback URL with query parameters such as id, first_name, username, auth_date, and hash. Because these parameters travel through the user's browser, anyone can open developer tools or manually construct a URL with arbitrary values—such as id=1 and username=admin.

If your web application trusts the incoming id parameter directly without verifying the cryptographic signature, attackers can impersonate any registered user on your system. Even if you check that the hash parameter exists, subtle implementation errors in calculating the HMAC-SHA-256 signature—such as incorrect string formatting, missing key sorting, or using raw token strings instead of hashed secret keys—will leave your login flow exposed or completely broken.

In this walkthrough, we will build a secure Telegram Login Widget verification pipeline inside a Yii2 application. We will analyze how attackers exploit naive authentication logic, implement a dedicated cryptographic validator component, handle safe account binding, and enforce database constraints to block duplicate identity claims.

The Mechanics of Telegram Login Widget Signatures

When Telegram issues an authentication callback, it sends user profile fields along with a hexadecimal hash. To verify that the payload originated from Telegram and was not altered in transit, your backend must reconstruct the raw verification string and re-compute the HMAC using a secret key derived from your bot's HTTP API token.

The verification algorithm requires strict adherence to specific formatting rules:

  1. Filter out the incoming hash parameter from the parameter set, as it is the signature you are testing against.
  2. Sort all remaining key-value pairs alphabetically by key name in ascending natural order.
  3. Format each pair as key=value strings.
  4. Concatenate these formatted pairs with a newline character (\n) as the delimiter.
  5. Compute the secret key by taking the raw SHA-256 binary hash of your bot token (hash('sha256', $botToken, true)). Note that this differs from Telegram Mini App initData, which uses an HMAC derivative prefixed with WebAppData.
  6. Generate an HMAC-SHA-256 hash of the concatenated string using the derived binary secret key.
  7. Compare the calculated hexadecimal string against the received hash parameter using a timing-attack safe comparison function.

A common security flaw occurs when developers perform string comparison using standard equality operators like == or ===. Standard string comparisons exit early as soon as the first non-matching byte is found. Attackers can exploit these microscopic execution timing differences to brute-force valid signatures byte by byte.

Another critical flaw is ignoring the auth_date parameter. Even if an attacker cannot forge a valid HMAC signature, they can intercept a legitimate URL containing a valid signature from a previous login session and re-submit it indefinitely. If your system accepts signatures regardless of when they were generated, your application is vulnerable to replay attacks.

Building a Cryptographically Secure Telegram Validator Component

To keep authentication logic clean and testable, we will encapsulate parameter extraction, secret key derivation, hash calculation, and timestamp checking inside a dedicated Yii2 component.

First, configure your bot token in your application configuration or environment variables. Never hardcode sensitive API tokens directly into source files.

<?php

namespace app\components;

use yii\base\Component;
use yii\base\InvalidArgumentException;

class TelegramAuthValidator extends Component
{
    public string $botToken;
    public int $maxAuthAge = 86400; // 24 hours in seconds

    public function init(): void
    {
        parent::init();
        if (empty($this->botToken)) {
            throw new InvalidArgumentException('The botToken property must be configured.');
        }
    }

    /**
     * Validates incoming query parameters from the Telegram Login Widget.
     *
     * @param array $params Query parameters (e.g., Yii::$app->request->get())
     * @return bool True if signature and auth_date are valid
     */
    public function validate(array $params): bool
    {
        if (!isset($params['hash'], $params['auth_date'])) {
            return false;
        }

        $checkHash = $params['hash'];
        $authDate = (int)$params['auth_date'];

        // Reject stale payloads to prevent replay attacks
        if ((time() - $authDate) > $this->maxAuthAge) {
            return false;
        }

        // Remove hash from dataset before sorting
        unset($params['hash']);

        $dataCheckArr = [];
        foreach ($params as $key => $value) {
            $dataCheckArr[] = $key . '=' . $value;
        }

        // Telegram requires alphabetical sorting by key name
        sort($dataCheckArr, SORT_STRING);
        $dataCheckString = implode("\n", $dataCheckArr);

        // Secret key is the raw SHA-256 hash of the bot token
        $secretKey = hash('sha256', $this->botToken, true);
        $calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

        // Timing-attack safe comparison
        return hash_equals($calculatedHash, $checkHash);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down the security guarantees in TelegramAuthValidator.php. First, missing mandatory keys (hash or auth_date) result in immediate rejection without running unnecessary hashing operations. Next, we enforce the age window by comparing time() - $authDate against $maxAuthAge. If a user presenting credentials generated two days ago attempts to log in, the check fails.

The secret key derivation uses hash('sha256', $this->botToken, true). The third argument (true) instructs PHP to return raw binary data instead of lower-case hex characters. Using hex strings produces invalid HMAC signatures, causing genuine logins to fail.

Finally, sort($dataCheckArr, SORT_STRING) ensures exact natural key sorting, and hash_equals() provides constant-time string comparison, eliminating timing side-channels.

Handling Account Binding in a Yii2 Controller

Once cryptographic validation is guaranteed, the application must process the user inside Yii2's identity model. The application must safely handle two scenarios:

  1. A logged-in user linking their account to Telegram.
  2. An unauthenticated guest logging in or creating a new account via Telegram.

If an attacker attempts to link a Telegram account that is already bound to another profile, the controller must block the attempt to prevent account displacement.

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use app\models\User;
use app\components\TelegramAuthValidator;

class AuthController extends Controller
{
    public function actionTelegramCallback()
    {
        $request = Yii::$app->request;
        $params = $request->get();

        /** @var TelegramAuthValidator $validator */
        $validator = Yii::$app->get('telegramValidator');

        if (!$validator->validate($params)) {
            Yii::$app->session->setFlash('error', 'Invalid or expired Telegram authentication signature.');
            return $this->redirect(['site/login']);
        }

        $telegramId = (int)$params['id'];
        $firstName = $params['first_name'] ?? '';
        $username = $params['username'] ?? '';

        if (!Yii::$app->user->isGuest) {
            // Scenario 1: Logged-in user binding Telegram account
            $currentUser = Yii::$app->user->identity;

            $existingOwner = User::findOne(['telegram_id' => $telegramId]);
            if ($existingOwner && $existingOwner->id !== $currentUser->id) {
                Yii::$app->session->setFlash('error', 'This Telegram account is already linked to another user.');
                return $this->redirect(['user/profile']);
            }

            $currentUser->telegram_id = $telegramId;
            $currentUser->save(false, ['telegram_id']);

            Yii::$app->session->setFlash('success', 'Telegram account linked successfully.');
            return $this->redirect(['user/profile']);
        }

        // Scenario 2: Guest logging in via Telegram
        $user = User::findOne(['telegram_id' => $telegramId]);

        if (!$user) {
            // Auto-register guest user or redirect to registration completion
            $user = new User();
            $user->username = $username ?: 'tg_' . $telegramId;
            $user->telegram_id = $telegramId;
            $user->generateAuthKey();

            if (!$user->save()) {
                Yii::$app->session->setFlash('error', 'Failed to create user account.');
                return $this->redirect(['site/login']);
            }
        }

        Yii::$app->user->login($user, 3600 * 24 * 30);
        return $this->goHome();
    }
}
Enter fullscreen mode Exit fullscreen mode

In AuthController.php, incoming parameters are fetched directly from the request object and immediately passed to the validator component. If verification fails, the controller flashes an error and aborts authentication without querying user tables.

When handling logged-in users, the controller checks whether $telegramId belongs to another record ($existingOwner->id !== $currentUser->id). Re-linking an occupied telegram_id is denied, protecting users from account takeover attempts.

Database Schema Enforcements to Prevent Race Conditions

Application-level checks alone cannot prevent race conditions under concurrent HTTP requests. If two identical requests pass HMAC validation at the same time, both could pass User::findOne(['telegram_id' => $telegramId]) before either saves, leading to duplicate records.

To prevent race conditions, apply a unique database index on the telegram_id column using a Yii2 migration.

<?php

use yii\db\Migration;

class m231025_100000_add_telegram_id_to_user_table extends Migration
{
    public function safeUp()
    {
        $this->addColumn('{{%user}}', 'telegram_id', $this->bigInteger()->null()->unique());
        $this->createIndex(
            'idx-user-telegram_id',
            '{{%user}}',
            'telegram_id',
            true
        );
    }

    public function safeDown()
    {
        $this->dropIndex('idx-user-telegram_id', '{{%user}}');
        $this->dropColumn('{{%user}}', 'telegram_id');
    }
}
Enter fullscreen mode Exit fullscreen mode

Using a bigInteger type accommodates Telegram's 64-bit integer IDs. The unique index forces the database engine to enforce uniqueness atomically, throwing a database exception if concurrent requests attempt duplicate assignment.

Production Considerations

When running Telegram Login Widget authentication in production, apply these additional security controls:

  1. Session Regeneration: Always ensure Yii2 regenerates session IDs upon login to block session fixation attacks. Yii2's default WebUser::login() method automatically rotates identity tokens.
  2. Shorten Timestamp Tolerances: While 86400 seconds (24 hours) is a standard upper limit, lowering $maxAuthAge to 300 seconds (5 minutes) in high-security web applications reduces the window for replay attacks if browser histories or access logs leak callback URLs.
  3. Input Sanitization: Treat fields like first_name and username as untrusted content. Always wrap user output in \yii\helpers\Html::encode() inside view templates to prevent Cross-Site Scripting (XSS).

If you need custom integration work or scalable backend structures for Telegram systems, BotCreator builds production-ready Telegram bots and Mini Apps with secure authentication architecture.

Top comments (0)