The Deep Linking mechanism in Telegram allows passing arbitrary data to a bot when a user follows a special link in the format https://t.me/UsernameBot?start=payload. Unlike the standard click on the "Start" button, passing the payload parameter makes it possible to link user actions on an external web platform with their session in the messenger — for example, passing an ad campaign ID, linking an account to a CRM lead, or activating an invitation token.
However, incorrect handling of this parameter creates critical vulnerabilities: from IDOR (Insecure Direct Object References) to the possibility of tampering with other users' order IDs and replay attacks by duplicating requests. In this article, we will look at the architecture of using Deep Linking, the strict limitations of the Telegram Bot API, and a reliable processing scheme with protection in PHP.
Telegram Bot API Limitations for the start Parameter
The payload parameter is passed by the webhook as part of a regular text message containing the /start payload command. Telegram servers impose strict technical limitations on the format of this parameter:
- Allowed characters: exclusively Latin letters (A-Z, a-z), digits (0-9), hyphen (
-), and underscore (_). Special characters, spaces, equal signs, and slashes are not allowed. - String length: no more than 64 bytes. Attempting to pass a longer string will result in Telegram either truncating the value or the link simply not opening the bot with the parameter.
- Delivery format: when following the link, the Telegram client automatically substitutes the payload as an argument to the
/startcommand. If the user has already had a dialogue with the bot, clicking the deeplink will send the/start payloadcommand to the chat as a regular incoming message.
Since the 64-character limit does not allow passing a full JSON or composite object, developers have to choose between two strategies: compressing the structure with a signature (HMAC) or using an opaque random token (Opaque Token) with data storage on the database side.
Strategy 1: Protecting the Passed ID Using HMAC
If you need to pass a public entity ID (for example, a lead ID or promo code) directly in the parameter, never pass it in plain text (?start=1054). Any user would be able to enumerate the numbers and gain access to other people's data. To prevent tampering, you need to sign the payload using an HMAC-SHA256 cryptographic hash truncated to a safe length.
Example of generating a signed Deep Link in PHP:
<?php
declare(strict_types=1);
function generateSignedDeepLink(string $botUsername, string $entityType, int $entityId, string $secretKey): string
{
// Формируем базовую строку: e.g. "lead_1054"
$rawPayload = sprintf('%s_%d', $entityType, $entityId);
// Генерируем краткую HMAC-подпись (10 символов)
$hash = substr(hash_hmac('sha256', $rawPayload, $secretKey), 0, 10);
// Финальный payload: "lead_1054_a8f3b2c1e4"
$payload = sprintf('%s_%s', $rawPayload, $hash);
if (strlen($payload) > 64) {
throw new InvalidArgumentException('Payload exceeds Telegram 64-byte limit.');
}
return sprintf('https://t.me/%s?start=%s', $botUsername, $payload);
}
$botUsername = getenv('TELEGRAM_BOT_USERNAME') ?: 'MyCompanyBot';
$secretKey = getenv('APP_SECRET_KEY') ?: 'hard_to_guess_secret_key_99';
// Генерируем ссылку для привязки лида №1054
$deepLink = generateSignedDeepLink($botUsername, 'lead', 1054, $secretKey);
// Ссылка: https://t.me/MyCompanyBot?start=lead_1054_a8f3b2c1e4
Strategy 2: Opaque Tokens and Entity Mapping in the DB
The HMAC approach is convenient because it does not require pre-writing the token to the database, but it does reveal the structure of your IDs. For maximum security and when you need to pass a complex context, the Opaque Token approach is used: generating a random 14-character hex identifier and writing its association with the entity to the database.
Example of generating a one-time link with writing a lead to the database:
<?php
declare(strict_types=1);
function createLeadDeepLink(PDO $pdo, string $botUsername, int $crmLeadId): string
{
// Генерируем ровно 14 hex-символов (7 байт)
$leadId = bin2hex(random_bytes(7));
$stmt = $pdo->prepare('
INSERT INTO telegram_deep_links (payload_token, entity_type, entity_id, is_used, created_at)
VALUES (:token, "lead", :entity_id, 0, NOW())
');
$stmt->execute([
'token' => $leadId,
'entity_id' => $crmLeadId,
]);
return sprintf('https://t.me/%s?start=%s', $botUsername, $leadId);
}
// Использование:
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$botUsername = getenv('TELEGRAM_BOT_USERNAME') ?: 'MyCompanyBot';
$secureLink = createLeadDeepLink($pdo, $botUsername, 8492);
// Ссылка: https://t.me/MyCompanyBot?start=4f8a9b1c2d3e5f
Processing an Incoming Webhook with Idempotency Check
When processing `/start payload` on the webhook side, it is important to follow three security rules:
- Secret Token Verification: validation of the
X-Telegram-Bot-Api-Secret-Tokenheader to protect the controller from unauthorized HTTP requests. - Idempotency by update_id: Telegram may resend the same update in case of network failures. You need to atomically record the
update_idin the database. - Safe String Comparison: verifying HMAC via
hash_equals()to prevent timing attacks.
Below is a full webhook handler in pure PHP that meets all the requirements:
<?php
declare(strict_types=1);
// 1. Проверка секретного токена вебхука
$secretToken = getenv('TELEGRAM_SECRET_TOKEN') ?: 'my_super_secret_webhook_token';
$receivedToken = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (!hash_equals($secretToken, $receivedToken)) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access']);
exit;
}
$rawInput = file_get_contents('php://input');
$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON payload']);
exit;
}
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 2. Гарантия идемпотентности через обработку update_id
$stmt = $pdo->prepare('INSERT INTO telegram_processed_updates (update_id) VALUES (:id) ON CONFLICT (update_id) DO NOTHING');
$stmt->execute(['id' => $update['update_id']]);
if ($stmt->rowCount() === 0) {
// Данный update_id уже был успешно обработан ранее
http_response_code(200);
echo json_encode(['ok' => true, 'status' => 'already_processed']);
exit;
}
// 3. Анализ сообщения
$message = $update['message'] ?? null;
if ($message && isset($message['text'])) {
$chatId = (int)$message['chat']['id'];
$text = trim($message['text']);
if (str_strt_with($text, '/start ')) {
$payload = trim(substr($text, 7));
handleStartPayload($pdo, $chatId, $payload);
}
}
http_response_code(200);
echo json_encode(['ok' => true]);
function str_strt_with(string $haystack, string $needle): bool {
return strncmp($haystack, $needle, strlen($needle)) === 0;
}
function handleStartPayload(PDO $pdo, int $chatId, string $payload): void
{
$secretKey = getenv('APP_SECRET_KEY') ?: 'hard_to_guess_secret_key_99';
$parts = explode('_', $payload);
// Вариант 1: Проверка HMAC payload (lead_1054_a8f3b2c1e4)
if (count($parts) === 3) {
[$type, $idStr, $hash] = $parts;
$expectedHash = substr(hash_hmac('sha256', $type . '_' . $idStr, $secretKey), 0, 10);
if (hash_equals($expectedHash, $hash)) {
$leadId = (int)$idStr;
// Связываем Telegram Chat ID с сущностью лида
$stmt = $pdo->prepare('UPDATE crm_leads SET telegram_chat_id = :chat_id, status = "attached" WHERE id = :lead_id');
$stmt->execute(['chat_id' => $chatId, 'lead_id' => $leadId]);
sendTelegramResponse($chatId, "Ваша заявка №{$leadId} успешно привязана к аккаунту!");
return;
}
}
// Вариант 2: Проверка Opaque Token в БД
$stmt = $pdo->prepare('SELECT entity_type, entity_id, is_used FROM telegram_deep_links WHERE payload_token = :token LIMIT 1');
$stmt->execute(['token' => $payload]);
$linkData = $stmt->fetch(PDO::FETCH_ASSOC);
if ($linkData) {
if ((int)$linkData['is_used'] === 1) {
sendTelegramResponse($chatId, "Эта ссылка уже была использована ранее.");
return;
}
$entityId = (int)$linkData['entity_id'];
// Помечаем токен использованным и привязываем чат
$pdo->beginTransaction();
$updateToken = $pdo->prepare('UPDATE telegram_deep_links SET is_used = 1, used_by_chat_id = :chat_id WHERE payload_token = :token');
$updateToken->execute(['chat_id' => $chatId, 'token' => $payload]);
$updateLead = $pdo->prepare('UPDATE crm_leads SET telegram_chat_id = :chat_id WHERE id = :lead_id');
$updateLead->execute(['chat_id' => $chatId, 'lead_id' => $entityId]);
$pdo->commit();
sendTelegramResponse($chatId, "Спасибо! Данные успешно синхронизированы.");
return;
}
sendTelegramResponse($chatId, "Передан недействительный или устаревший параметр запуска.");
}
function sendTelegramResponse(int $chatId, string $text): void
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
return;
}
$url = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]),
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false || $httpCode !== 200) {
$error = curl_error($ch);
curl_close($ch);
error_log("Telegram API Error: HTTP {$httpCode}, CurlError: {$error}");
return;
}
curl_close($ch);
$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($responseData['ok']) || $responseData['ok'] !== true) {
error_log("Telegram API Error response: