Overview
A Telegram Mini App built with React needs a reliable way to prove its identity to users and your backend. This guide shows how to:
1. Accept an initData header from the Telegram client. 2. Validate the data structure and extract the embedded user ID. 3. Issue a signed JWT on your server after confirming the payload matches expectations. 4. Reject requests where the user ID doesn't match the authenticated session.
This pattern prevents spoofed requests and ensures only authorized mini apps can interact with your backend.
Architecture
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ React │◄────►│ Telegram Client │◄────►│ Your API │
│ Mini App │ │ (initiData) │ │ (PHP/Node) │
└─────────────┘ └──────────────────┘ └─────────────┘
│ │ \
▼ ▼ \
initData header JWT issuance Token verification
Step 1 – Backend: Receive initData and Issue JWT
On your PHP side (e.g., a Laravel controller or plain script), listen for the Telegram webhook or direct API call. Extract initData, validate it, and return a JWT if everything looks good.
<?php
// Minimal PHP handler (can be adapted to Laravel)
require 'vendor/autoload.php';
use Illuminate\HttpRequest;
use Illuminate\Support\Facades\Route;
// Secret used to sign/verify JWTs
const JWT_SECRET = env('JWT_SECRET', 'your-secret-change-me');
// Expected initData format:
// { "userId": "abc123", "token": "xyz789" }
// The userId comes from your database lookup.
function handleInitData(Request $request): Response {
// 1. Read initData from the header
$initData = $request->header('X-InitData');
if (!$initData) {
return response()->json(['error' => 'Missing initData'], 400);
}
// 2. Decode and validate the payload
$payload = json_decode($initData, true);
if (!is_array($payload) || !isset($payload['userId']) && !isset($payload['token'])) {
return response()->json(['error' => 'Invalid initData'], 400);
}
// 3. Verify the user ID exists in your system (prevents spoofing)
$user = User::find($payload['userId']);
if (!$user) {
// Log the attempt but do NOT reveal whether the user exists
error_log("Unauthorized initData attempt: user_id=\{payload['userId']}");
return response()->json(['error' => 'Unauthorized'], 401);
}
// 4. Issue a fresh JWT for subsequent calls
$jwt = (\.jsonwebtoken\JWT::encode(
['sub' => $user->id, 'exp' => time() + 3600]
), JWT_SECRET);
// Return the JWT so the frontend can store it
return response()->json(['jwt' => $jwt]);
}
Key security decisions: - The userId inside initData is checked against your database before trusting anything else. - Only after a successful lookup do you issue a new JWT. This ties the initial auth to a fresh token each time. - Failed lookups return 401 Unauthorized without revealing which field was missing.
Step 2 – Frontend: Send initData from React
In your React component, construct the request with the initData header and include the JWT in the body (or as a query param depending on your flow).
import React, { useState } from 'react';
import axios from 'axios';
const TelegramMiniApp = () => {
const [jwt, setJwt] = useState(null);
const submitToTelegram = async () => {
// Fetch the JWT from your backend first
const res = await fetch('/api/init-data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error('Failed to obtain JWT');
const { jwt } = await res.json();
setJwt(jwt);
// Now send the request with both initData and JWT
try {
const resp = await axios.post(
'https://yourapp.com/api/notify',
{ action: 'submit_form' },
{
headers: {
'Authorization': `Bearer ${jwt}`,
'X-InitData': JSON.stringify({ /* optional extra fields */ }),
},
}
);
console.log('Form submitted:', resp.data);
} catch (err) {
console.error('Submission failed:', err);
}
};
return (
<div>
<button onClick={submitToTelegram} disabled={!jwt || !jwt.startsWith('eyJ')}>
Submit
</button>
{jwt && <p>Authenticated (JWT expires in {Math.floor((Date.now() + 3600 - Date.now()) / 1000)}s)</p>}
</div>
);
};
export default TelegramMiniApp;
### Why two headers? - X-InitData: Contains the opaque userId and any additional context the client needs. It's validated server-side to prevent tampering. - Authorization: Bearer <jwt>: Verifies the request came from an authenticated session. The JWT itself is verified by your backend before reaching this point.
Step 3 – Server-Side Verification Flow
When your backend receives a POST from the mini app (e.g., /api/notify), follow this sequence:
1. Check the JWT signature using your secret (Laravel: \.jsonwebtoken\JWT::verify()). 2. Extract the userId from the decoded payload. 3. Look up the corresponding user in your database. If not found, reject with 401. 4. Proceed with the actual business logic (e.g., save form data, trigger actions).
<?php
require 'vendor/autoload.php';
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\JWT\RefreshToken;
// Simulated user model
class User {
public function __construct(
public int $id,
public string $email,
public ?string $realUserId = null
) {}
}
function processNotify(Request $request) {
// 1. Verify JWT
$jwt = $request->header('Authorization');
if (!$jwt) {
return response()->json(['error' => 'Missing JWT'], 401);
}
try {
$decoded = \\JsonWebToken::decode($jwt, JWT_SECRET);
$expectedUserId = $decoded->get('sub');
} catch (\\Exception $e) {
return response()->json(['error' => 'Invalid JWT'], 401);
}
// 2. Check that the user exists in our DB
// In a real app, use Eloquent: User::where('id', $expectedUserId)->first();
// For demo, we simulate:
if ($expectedUserId === 'nonexistent-id') {
return response()->json(['error' => 'Unauthorized'], 401);
}
// 3. Proceed with the actual work
// ... save data, etc.
return response()->json(['status' => 'ok']);
}
Step 4 – Preventing User ID Spoofing
The most common attack vector is a malicious client crafting initData with a fake userId. Our defense is layered:
| Layer | Mechanism | |-------|-----------| | Header validation | Server checks that userId appears in the initData payload. | | Database lookup | After extracting userId, we verify it exists in our users table. | | JWT binding | The JWT carries the canonical sub (user ID) and is verified independently. |
If any layer fails, the request is rejected before any business logic runs.
Production Notes
- Store the JWT securely (httpOnly cookies or secure storage) on the client side; never log it. - Rotate secrets regularly and keep them out of version control. - Rate-limit the /api/init-data endpoint to mitigate brute-force attempts. - Use HTTPS for all communication between the mini app and your backend. - Consider adding a CSRF-like guard for sensitive operations beyond just the initData exchange.
Further Reading
For more details on the Telegram Bot API and best practices for Mini Apps, see Telegram Bot API documentation. You can also explore building a Telegram Mini App with React for deeper integration patterns.
BotCreator — studio that ships Telegram bots / Mini Apps.