Securing Your Telegram Mini App with InitData and JWT
Building a secure Telegram Mini App requires a robust authentication strategy, especially when interacting with your custom backend API. This guide details how to authenticate a React-based Mini App against a PHP API using Telegram's initData and JSON Web Tokens (JWT).
The core of this authentication flow involves two primary steps: verifying the integrity of the data sent from the Mini App via initData, and then using that verified data to issue secure, short-lived JWTs for subsequent API access.
Understanding the Threat Model
It's crucial to establish a realistic threat model for Telegram Mini Apps. We are not aiming to prevent a determined attacker who controls both the client application and the network traffic. Instead, the goal is to significantly raise the bar for attackers. This approach focuses on mitigating threats that involve forging client-side data or intercepting sensitive information without proper authorization. The primary defenses are against unauthorized access that doesn't involve compromising Telegram's signing mechanisms or stealing your Bot Token directly. This means an attacker would need to either compromise Telegram's internal signing process or gain possession of your Bot Token to impersonate a user or forge initData.
The Authentication Flow
The authentication process can be broken down into a two-sided flow:
- Client-Side (React Mini App): The React Mini App leverages the
@twa-dev/sdklibrary to access theinitData. This data, containing user and chat information signed by Telegram, is then attached to every API request made to your backend. It's typically sent as a custom HTTP header, for example,X-Telegram-InitData. - Server-Side (PHP Backend): Upon receiving a request, the PHP backend first validates the attached
initData. This validation uses the HMAC-SHA-256 algorithm, with your Telegram Bot Token acting as the secret key. This step ensures the data hasn't been tampered with and is genuinely from Telegram. If the validation passes, the backend extracts thetelegram_idfrom the verifiedinitData. Thistelegram_idis then used to generate a short-lived JWT. This JWT is signed using a server-side secret key (distinct from the Bot Token) and contains thetelegram_idas a claim. - Protected Endpoints: All subsequent requests to protected resources on your API must include this JWT, typically in the
Authorizationheader (e.g.,Bearer). The backend verifies this JWT: it checks the signature using its secret key and ensures thetelegram_idclaim matches the expected user for that specific resource. Requests with invalid or expired JWTs are rejected.
Verifying InitData in PHP
The initData is a JSON string containing various user and chat details, along with a hash parameter. This hash is an HMAC-SHA-256 signature of the concatenated key-value pairs of the initData. To verify it, you need your Bot Token.
Here's a conceptual PHP implementation:
function verifyTelegramInitData(string $initData, string $botToken): ?array {
$data = [];
parse_str($initData, $data);
if (!isset($data['hash'])) {
return null; // Hash is missing
}
$hash = $data['hash'];
unset($data['hash']);
// Sort keys alphabetically
ksort($data);
// Reconstruct the string to be signed
$dataToSign = '';
foreach ($data as $key => $value) {
$dataToSign .= "$key=$value\n";
}
// Remove trailing newline
$dataToSign = rtrim($dataToSign, "\n");
// Calculate the expected hash
$expectedHash = hash_hmac('sha256', $dataToSign, "WebAppData{$botToken}");
// Compare hashes
if (!hash_equals($expectedHash, $hash)) {
return null; // Hash mismatch, data is not authentic
}
// Basic check for expiration (optional but recommended)
if (isset($data['auth_date']) && $data['auth_date'] < time() - 10 * 60) { // 10 minutes
return null; // Data is too old
}
return $data; // Data is valid
}
This function takes the raw initData string and your Bot Token. It parses the data, reconstructs the signed string, calculates the HMAC-SHA-256 hash using the Bot Token, and compares it with the provided hash. It also includes a basic check for the auth_date to ensure the data isn't stale. A successful verification returns the parsed data array; otherwise, it returns null.

Issuing JWTs
Once initData is verified, you can extract the telegram_id. This ID is the unique identifier for the user within Telegram and is suitable for use as the subject (sub) claim in your JWT. You'll need a robust JWT library for PHP. Libraries like firebase/php-jwt are excellent choices.
When issuing a JWT, consider the following:
- Issuer (
iss): Your API's domain. - Audience (
aud): Your API's domain or a specific service identifier. - Subject (
sub): The verifiedtelegram_id. - Issued At (
iat): The timestamp when the token was issued. - Expiration Time (
exp): A short expiration time (e.g., 15-60 minutes) to limit the window of opportunity for token theft.
Your server-side secret key for signing JWTs should be securely stored and ideally rotated periodically. Never use your Telegram Bot Token for signing JWTs.
require 'vendor/autoload.php'; // Assuming you use Composer
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function generateUserToken(string $telegramId, string $jwtSecretKey, int $expirationSeconds = 3600): string {
$issuedAt = time();
$expiration = $issuedAt + $expirationSeconds;
$payload = [
'iss' => $_SERVER['HTTP_HOST'], // Your API domain
'aud' => $_SERVER['HTTP_HOST'], // Or a specific service identifier
'iat' => $issuedAt,
'exp' => $expiration,
'sub' => $telegramId, // The Telegram User ID
];
return JWT::encode($payload, $jwtSecretKey, 'HS256');
}
// Example usage after initData verification:
// $telegramData = verifyTelegramInitData($_SERVER['HTTP_X_TELEGRAM_INITDATA'], $botToken);
// if ($telegramData && isset($telegramData['id'])) {
// $userJwt = generateUserToken($telegramData['id'], $yourJwtSecretKey);
// // Send $userJwt back to the client
// }
Verifying JWTs on the Backend
When a request arrives with a JWT, your PHP API must decode and verify it. The JWT library will handle signature verification and check standard claims like exp and iat.
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
try {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (strpos($authHeader, 'Bearer ') === 0) {
$jwt = substr($authHeader, 7);
} else {
throw new Exception('Invalid Authorization header format.');
}
$decoded = JWT::decode($jwt, new Key($yourJwtSecretKey, 'HS256'));
// Now $decoded contains the payload, including 'sub' (telegram_id)
$telegramUserId = $decoded->sub;
// Further checks: Ensure $telegramUserId is allowed to access the requested resource.
// For example, check if this user owns the data they are trying to retrieve.
// If all checks pass, proceed with the request.
} catch (ExpiredException $e) {
// Token has expired
http_response_code(401);
echo json_encode(['error' => 'Token expired']);
exit;
} catch (SignatureInvalidException $e) {
// Signature is invalid
http_response_code(401);
echo json_encode(['error' => 'Invalid token signature']);
exit;
} catch (Exception $e) {
// Other errors (e.g., malformed token, invalid header)
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'message' => $e->getMessage()]);
exit;
}
This verification process ensures that only authenticated users with valid, unexpired tokens can access protected API endpoints. The telegram_id extracted from the JWT can then be used to authorize actions specific to that user.
Client-Side Implementation (React)
On the React side, you'll use the @twa-dev/sdk library to get the initData and then configure your API client (e.g., Axios, Fetch API) to include it in headers. After receiving a JWT from your API, you'll store it (e.g., in local storage or session storage, or memory) and include it in subsequent requests.
import WebApp from '@twa-dev/sdk';
import axios from 'axios';
// Initialize SDK
WebApp.ready();
// Get initData
const initData = WebApp.initData;
// Configure Axios instance
const apiClient = axios.create({
baseURL: 'https://your-api.com',
headers: {
'X-Telegram-InitData': initData,
'Content-Type': 'application/json',
}
});
// Function to set JWT after login/initial auth
export const setAuthToken = (token) => {
if (token) {
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// Optionally store token for persistence
localStorage.setItem('authToken', token);
} else {
delete apiClient.defaults.headers.common['Authorization'];
localStorage.removeItem('authToken');
}
};
// Example of an initial authentication request to your PHP API
const authenticateUser = async () => {
try {
// This request will carry initData in headers
const response = await apiClient.post('/authenticate');
if (response.data.token) {
setAuthToken(response.data.token);
console.log('Authentication successful!');
}
} catch (error) {
console.error('Authentication failed:', error);
}
};
// Call this when your app loads or user initiates auth
// authenticateUser();
// Example of a protected API call after authentication
const fetchProtectedData = async () => {
try {
// This request will carry the Bearer token
const response = await apiClient.get('/protected-resource');
console.log('Protected data:', response.data);
} catch (error) {
console.error('Failed to fetch protected data:', error);
}
};
// fetchProtectedData();
This setup ensures that the first request carries the initData for initial verification. Once the PHP API responds with a JWT, the client stores it and uses it for all subsequent authenticated requests. This layered approach provides a strong security posture for your Telegram Mini App interactions.
Conclusion
By implementing this two-stage authentication process—validating initData via HMAC-SHA-256 on your PHP backend and subsequently using short-lived JWTs for API access—you significantly enhance the security of your React-based Telegram Mini Apps. This method protects against common threats and ensures that only legitimate users can access sensitive data and functionality.
