What we build
A two-side authentication flow:
- The React Mini App reads
initDatafrom@twa-dev/sdkand attaches it to every API request as a custom header. - 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. - Protected endpoints verify the JWT and refuse requests where the
telegram_idclaim 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 withauth_dateexpiry 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_idand 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.initDatais the raw string Telegram injects. Send that, not a re-serialized version. The server must parse it exactly. -
WebApp.initDataUnsafeis 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 frominitDataand 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:
- Parse
initDataaskey=valuepairs separated by&, keeping URL-decoded values. - Build the
data_check_stringby concatenating every pair excepthash, sorted by key, joined with\n. - Compute
HMAC-SHA-256(key=BOT_TOKEN, msg="WebAppData" + data_check_string). - Compare with
hashusinghash_equals. - Reject if
auth_dateis 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.
localStorageis 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/refreshthat acceptsinitDataagain, re-verifies it, and returns a fresh JWT. Do not accept a refresh request without a freshinitDatasignature. -
Logging. Log
telegram_id,auth_date, and a truncated hash. Never log the rawinitDataquery string — it contains user data. -
Clock skew. Compare with
auth_dateusing 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 differentdata_check_string. Do not mix them.
6. What to verify end-to-end
A short manual test before you ship:
- Open the Mini App from Telegram.
/auth/telegramreturns 200 and a JWT. - Replay the same
X-Tg-Init-Dataheader fromcurl6 minutes later. Expect 401 "initData expired". - Mutate one byte of
initDataand replay it. Expect 401 "invalid initData". - 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.
Top comments (0)