What we build
A two-side authentication flow:
1. The React Mini App reads initData from @twa-dev/sdk and attaches it to every API request as a custom header. 2. A PHP backend validates that header with HMAC-SHA-256 (the Bot Token as the key), rejects anything forged or stale, then issues a short-lived JWT bound to the verified telegram_id. 3. Protected endpoints verify the JWT and refuse requests where the telegram_id claim does not match what the server expects for that resource.
We do not claim this prevents a determined attacker who controls both the client and the network. What it does do is raise the bar to "must control Telegram's signing path or steal your Bot Token", which is the realistic threat model for a Mini App.
---
1. The threat model, briefly
Three things you actually need to defend against in a Mini App that calls your API:
- Replaying a stolen initData: stop with auth_date expiry and a small per-session nonce. - Forging a telegram_id: stop by recomputing the HMAC on the server with your Bot Token. The client never chooses the user id; the signature does. - Reusing a JWT issued for user A against user B's resource: stop by binding the JWT subject to telegram_id and checking it on every protected endpoint, not just on login.
That is the entire checklist. Everything below maps to one of those.
---
2. React side: ship initData with every request
Install the SDK and an HTTP client. We use fetch directly to keep the tutorial transport-agnostic, but the same pattern works with axios interceptors.
npm i @twa-dev/sdk
A minimal client wrapper:
// 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();
}
A login button:
// 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>;
}
Two things to keep in mind:
- WebApp.initData is the raw string Telegram injects. Send that, not a re-serialized version. The server must parse it exactly. - WebApp.initDataUnsafe is for UI hints (showing the user's first name, hiding a button for non-admins). Never trust it on the server. The server recomputes everything from initData and the Bot Token.
---
3. PHP side: verify initData and mint a JWT
We split this into three small pieces: a verifier, a JWT helper, and the route.
The verifier implements the official Telegram check:
1. Parse initData as key=value pairs separated by &, keeping URL-decoded values. 2. Build the data_check_string by concatenating every pair except hash, sorted by key, joined with \n. 3. Compute HMAC-SHA-256(key=BOT_TOKEN, msg="WebAppData" + data_check_string). 4. Compare with hash using hash_equals. 5. Reject if auth_date is older than ~5 minutes (or whatever your session lifetime is).
// 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 helper using firebase/php-jwt. Keep the secret out of the repo; read it from the environment.
// 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));
}
}
The route. Note three things: we read the Bot Token and JWT secret from environment, we JSON-decode user and pull id only after the HMAC check passes, and we mint a JWT whose sub is the verified telegram_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;
}
That is the entire login flow. The client stores it, the server trusts nothing it did not sign.
---
4. Protected endpoints: bind the JWT to telegram_id
The JWT alone is not enough. If your route handles a resource that belongs to a specific Telegram user (an order, a subscription, a draft), the handler must compare claims->tg against the resource owner before doing anything.
// 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;
}
And a route that uses it:
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;
}
A JWT for user A will be rejected on user B's telegram_id, even if both are signed by the same secret. That is the "binding" the title refers to.
---
5. Production notes (optional, but worth it)
These are not required to make the flow work, but they are the difference between a demo and something you can ship.
- Token storage on the client. localStorage is fine for a Mini App because the origin is Telegram's WebView, not a hostile page. If you serve the same bundle from a public domain, prefer an HttpOnly cookie set by /auth/telegram. - Replay window. 5 minutes is the default above. Shorten it to 60 seconds if you mint per-request nonces; lengthen it only if you have a strict device-bound refresh flow. - Refresh. Issue a second endpoint /auth/refresh that accepts initData again, re-verifies it, and returns a fresh JWT. Do not accept a refresh request without a fresh initData signature. - Logging. Log telegram_id, auth_date, and a truncated hash. Never log the raw initData query string — it contains user data. - Clock skew. Compare with auth_date using server time, not client claims. time() is fine. - Webhook vs Mini App auth. This article covers Mini App initData. The Login Widget flow uses a different payload (id, first_name, auth_date, hash, ...) and a different data_check_string. Do not mix them.
---
6. What to verify end-to-end
A short manual test before you ship:
1. Open the Mini App from Telegram. /auth/telegram returns 200 and a JWT. 2. Replay the same X-Tg-Init-Data header from curl 6 minutes later. Expect 401 "initData expired". 3. Mutate one byte of initData and replay it. Expect 401 "invalid initData". 4. Send a valid JWT for user A but request /orders?telegram_id=B. Expect 403.
If all four pass, your auth path is doing what it should.
---
If you are putting a Mini App into production and want a team that has already shipped this kind of flow, BotCreator is a studio that builds Telegram bots and Mini Apps end-to-end and is worth a look. For a deeper dive into the Bot API surface (webhooks, rate limits, callback payloads), their Telegram Bot API reference is a useful bookmark.