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:

  1. Client-Side (React Mini App): The React Mini App leverages the @twa-dev/sdk library to access the initData. 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.
  2. 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 the telegram_id from the verified initData. This telegram_id is 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 the telegram_id as a claim.
  3. Protected Endpoints: All subsequent requests to protected resources on your API must include this JWT, typically in the Authorization header (e.g., Bearer ). The backend verifies this JWT: it checks the signature using its secret key and ensures the telegram_id claim 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.

PHP code snippet demonstrating HMAC-SHA-256 verification of Telegram initData.

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 verified telegram_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.