Аутентификация приложения React Telegram Mini по PHP API с помощью initData и JWT

ЧТО МЫ МОЖЕМ СОЗДАТЬ

Поток двухсторонней аутентификации:

1. Приложение React Mini считывает initData от @twa-dev/sdk и прикрепляет его к каждому запросу API в качестве пользовательского заголовка. 2. Сервер PHP проверяет этот заголовок с помощью HMAC-SHA-256 (токен бота в качестве ключа), отклоняет все поддельные или устаревшие, а затем выдает недолговечный JWT, привязанный к проверенному Телеграм ID. 3. Защищенные конечные точки проверяют JWT и отклоняют запросы, когда Телеграм ID утверждение не соответствует тому, что сервер ожидает для этого ресурса.

Мы not утверждают, что это предотвращает определенную атаку, которая контролирует как клиента, так и сеть. Что он делает, так это поднимает планку, чтобы «контролировать путь подписи Telegram или украсть ваш токен бота», что является реалистичной моделью угрозы для мини-приложения.

---

1. Модель угроз, кратко

Три вещи, от которых вам действительно нужно защититься в мини-приложении, которое вызывает ваш API:

- Воспроизведение украденного initData: остановить с помощью Дата авторизации истечения срока действия и небольшого одноразового сеанса. - Ковка Телеграм ID: остановитесь, пересчитав HMAC на сервере с помощью токена бота. Клиент никогда не выбирает идентификатор пользователя; это делает подпись. - Повторное использование JWT, выданного пользователю A, против ресурса пользователя B: остановить, связав JWT с Телеграм ID и проверять его на каждой защищенной конечной точке, а не только при входе в систему.

Вот и весь чек-лист. Все, что ниже, соответствует одному из них.

---

2. Сторона реакции: судно initData при каждом запросе

Установите SDK и HTTP-клиент. Мы используем приносить напрямую, чтобы учебник не зависел от транспорта, но тот же шаблон работает с перехватчиками axios.

npm i @twa-dev/sdk

Минимальная клиентская обертка:

// src/api/client.js
import WebApp from '@twa-dev/sdk';

const API_BASE = import.meta.env.VITE_API_BASE;

export async function api(path, options = {}) {
const initData = WebApp.initData; // raw query string, including hash
const initDataUnsafe = WebApp.initDataUnsafe; // parsed object, for client-side hints only

const headers = {
'Content-Type': 'application/json',
'X-Tg-Init-Data': initData,
...(options.headers || {}),
};

const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});

if (!res.ok) {
const text = await res.text();
throw new Error(`API ${res.status}: ${text}`);
}
return res.json();
}

Кнопка входа

// src/Login.jsx
import WebApp from '@twa-dev/sdk';
import { api } from './api/client';

export function Login() {
async function signIn() {
WebApp.readyToString();
const { token, user } = await api('/auth/telegram', { method: 'POST' });
localStorage.setItem('tg_jwt', token);
console.log('Signed in as', user.telegram_id);
}
return <button onClick={signIn}>Sign in with Telegram</button>;
}

Ряд моментов, которые следует иметь в виду:

- WebApp.initData это необработанная строка, которую вводит Telegram. Отправить что, а не ресериализованная версия. Сервер должен точно проанализировать его. - WebApp.initDataUnsafe для подсказок пользовательского интерфейса (отображение имени пользователя, скрытие кнопки для неадминистраторов). Никогда доверять ему на сервере. Сервер пересчитывает все из initData и токена бота.

---

3. Сторона PHP: проверить initData и отчеканить JWT

Мы разделили это на три маленькие части: верификатор, помощник JWT и маршрут.

Верификатор реализует официальную проверку Telegram:

1. Парсинг initData ХХХ значение ключа пары, разделенные &, сохраняя значения, декодированные URL-адресом. 2. Создайте data_check_string путем объединения каждой пары кроме хэшей, отсортировано по ключу, объединено с \n. 3. Вычислить HMAC-SHA-256(key=BOT_TOKEN, msg="WebAppData" + data_check_string). 4. Сравните с хэшей используя hash_equals. 5. Отклонить, если Дата авторизации старше ~5 минут (или независимо от продолжительности вашей сессии).

// src/Telegram/InitDataVerifier.php
namespace App\Telegram;

final class InitDataVerifier
{
public function __construct(private readonly string $botToken) {}

/** @return array<string,string> */
public function verify(string $initData, int $maxAgeSeconds = 300): array
{
$pairs = [];
foreach (explode('&', $initData) as $pair) {
if ($pair === '' || !str_contains($pair, '=')) continue;
[$k, $v] = explode('=', $pair, 2);
$pairs[urldecode($k)] = urldecode($v);
}

if (!isset($pairs['hash'], $pairs['auth_date'], $pairs['user'])) {
throw new \DomainException('initData missing required fields');
}

$receivedHash = $pairs['hash'];
unset($pairs['hash']);
ksort($pairs);

$dataCheckString = implode("\n", array_map(
fn($k, $v) => "$k=$v",
array_keys($pairs),
array_values($pairs)
));

$secret = hash_hmac('sha256', $this->botToken, 'WebAppData', true);
$computed = hash_hmac('sha256', $dataCheckString, $secret);

if (!hash_equals($computed, $receivedHash)) {
throw new \DomainException('initData signature mismatch');
}

if (time() - (int)$pairs['auth_date'] > $maxAgeSeconds) {
throw new \DomainException('initData expired');
}

return $pairs;
}
}

Помощник JWT с использованием firebase/php-jwt. Держите секрет подальше от репозитория; прочитайте его из окружающей среды.

// src/Auth/JwtIssuer.php
namespace App\Auth;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

final class JwtIssuer
{
public function __construct(
private readonly string $secret,
private readonly string $algo = 'HS256',
private readonly int $ttlSeconds = 3600,
) {}

public function issueForTelegramUser(int $telegramId, array $extra = []): string
{
$now = time();
return JWT::encode([
'sub' => (string)$telegramId,
'tg' => $telegramId,
'iat' => $now,
'nbf' => $now,
'exp' => $now + $this->ttlSeconds,
] + $extra, $this->secret, $this->algo);
}

/** @return object decoded claims */
public function decode(string $token): object
{
return JWT::decode($token, new Key($this->secret, $this->algo));
}
}

Маршрут. Обратите внимание на три вещи: мы читаем Bot Token и JWT секрет от окружения, мы JSON-декодируем пользователя и потяните id только после прохождения проверки HMAC, и мы чеканим JWT, чей суб является проверенным Телеграм ID.

// public/index.php (or your router)
use App\Telegram\InitDataVerifier;
use App\Auth\JwtIssuer;

$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '';
$jwtSecret = getenv('APP_JWT_SECRET') ?: '';
if ($botToken === '' || $jwtSecret === '') {
http_response_code(500); exit('server misconfigured');
}

$verifier = new InitDataVerifier($botToken);
$issuer = new JwtIssuer($jwtSecret);

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];

if ($path === '/auth/telegram' && $method === 'POST') {
$initData = $_SERVER['HTTP_X_TG_INIT_DATA'] ?? '';
if ($initData === '') { http_response_code(400); exit('missing initData'); }

try {
$claims = $verifier->verify($initData);
} catch (\Throwable $e) {
http_response_code(401); exit('invalid initData');
}

$user = json_decode($claims['user'], true);
if (!is_array($user) || !isset($user['id'])) {
http_response_code(400); exit('user field malformed');
}

$token = $issuer->issueForTelegramUser((int)$user['id']);
header('Content-Type: application/json');
echo json_encode([
'token' => $token,
'user' => ['telegram_id' => (int)$user['id'], 'name' => $user['first_name'] ?? ''],
]);
return true;
}

Это весь процесс входа в систему. Клиент хранит его, сервер не доверяет ничему, что он не подписал.

---

4. Защищенные конечные точки: привяжите JWT к Телеграм ID

Одного JWT недостаточно. Если ваш маршрут обрабатывает ресурс, который принадлежит конкретному пользователю Telegram (заказ, подписка, черновик), обработчик должен сравнить формула изобретения->tg против владельца ресурса, прежде чем что-либо делать.

// src/Auth/require_telegram_user.php
function requireTelegramUser(JwtIssuer $issuer, int $expectedTelegramId): object
{
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer (.+)$/', $auth, $m)) {
http_response_code(401); exit('missing bearer');
}
try {
$claims = $issuer->decode($m[1]);
} catch (\Throwable $e) {
http_response_code(401); exit('invalid token');
}
if ((int)($claims->tg ?? 0) !== $expectedTelegramId) {
http_response_code(403); exit('telegram id mismatch');
}
return $claims;
}

И маршрут, который его использует:

if ($path === '/orders' && $method === 'GET') {
$telegramId = (int)($_GET['telegram_id'] ?? 0);
requireTelegramUser($issuer, $telegramId);
$orders = $db->fetchAll('orders', ['telegram_id' => $telegramId]);
header('Content-Type: application/json');
echo json_encode($orders);
return true;
}

JWT для пользователя A будет отклонен пользователем B Телеграм ID, даже если оба подписаны одним и тем же секретом. То есть «обязательность», на которую ссылается заголовок.

---

5. Примечания к производству (необязательно, но оно того стоит)

Они не требуются для работы потока, но они представляют собой разницу между демонстрацией и тем, что вы можете отправить.

- Хранение токенов на клиенте. localStorage подходит для мини-приложения, потому что источником является WebView Telegram, а не враждебная страница. Если вы обслуживаете один и тот же пакет из общедоступного домена, отдайте предпочтение файлу cookie HttpOnly, установленному /auth/telegram. - Окно воспроизведения. 5 минут - это значение по умолчанию выше. Сократите его до 60 секунд, если вы чеканите одноразовые монеты; удлините его, только если у вас есть строгий поток обновления, связанный с устройством. - Обновить Выдать вторую конечную точку /auth/refresh который принимает initData снова, повторно проверяет его и возвращает свежий JWT. Сделайте not принять запрос на обновление без свежего initData подписью. Ведение журнала Журнал событий Телеграм ID, Дата авторизациии усеченный хэш. Никогда не регистрируйте RAW initData строка запроса — содержит пользовательские данные. - Перекос часов. Сравнить с Дата авторизации используя время сервера, а не утверждения клиента. время Нормально. Webhook vs Mini App auth. Эта статья посвящена приложению Mini initData. Поток виджета входа в систему использует другую полезную нагрузку (id, first_name, Дата авторизации, хэшей, ...) и другой data_check_stringНе смешивайте их.

---

6. Что проверять сквозно

Короткий ручной тест перед отправкой:

1. Откройте мини-приложение в Telegram. /auth/telegram возвращает 200 и JWT. 2. Воспроизведите то же самое X-Tg-Init-Data header from круг Через 6 минут. Ожидайте 401 "initData expired". 3. Изменить один байт из initData и повторите его. Ожидайте 401 "invalid initData". 4. Отправить действительный JWT для пользователя A, но запрос /orders?telegram_id=B. Ожидайте 403.

Если все четыре пройдены, ваш путь аутентификации делает то, что должен.

---

Если вы запускаете Mini App в производство и хотите команду, которая уже отгрузила такой поток, BotCreator - это студия, которая строит ботов Telegram и Mini Apps из конца в конец и стоит посмотреть. Для более глубокого погружения в поверхность API бота (веб-перехватчики, лимиты скорости, полезные нагрузки обратного вызова) их ссылка на API бота Telegram является полезной закладкой.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.