Building a Telegram Mini App with React often starts smoothly: you import @twa-dev/sdk or read window.Telegram.WebApp, grab initDataUnsafe.user.id, and immediately render your interface. If you rely on initDataUnsafe to identify the logged-in user or authorize transactions, your application contains a critical authentication bypass. The initDataUnsafe object is entirely untrusted client-side state. Anyone can open your Mini App outside the official Telegram client, mock the global Telegram object in browser developer tools, or modify JavaScript execution to claim any user ID they want.
When a React app trusts this spoofed payload to enable payment flows, claim rewards, or fetch user balances, an attacker can manipulate initDataUnsafe.user.id to impersonate high-value accounts. To build a secure Mini App, you must treat initDataUnsafe purely as a UI convenience for displaying non-sensitive placeholder data. Authentication must be backed by verifying the raw initData string on a secure backend using HMAC-SHA256 validation, enforcing an expiration threshold on auth_date, and keeping interactive elements like MainButton disabled until the server returns an explicit success response.
The Vulnerability: Reproducing an initDataUnsafe Spoof Attack
To understand why client-side data cannot be trusted, consider how Telegram passes user information to your Mini App. When opened inside a Telegram client, the webview receives a URL parameter named tgWebAppData. The SDK parses this parameter into two representations:
1. initData: A raw URL-encoded string containing user properties, authentication timestamps, query IDs, and a cryptographic hash generated by Telegram's servers using your bot token. 2. initDataUnsafe: A parsed JavaScript object generated client-side from that raw string.
Because initDataUnsafe lives in client memory, an attacker can manipulate it without touching Telegram's servers. Suppose your React app reads initDataUnsafe.user.id on mount and immediately displays the check-out interface:
// NAIVE IMPLEMENTATION - DO NOT USE IN PRODUCTION
import React, { useEffect, useState } from 'react';
import WebApp from '@twa-dev/sdk';
export function NaiveCheckout() {
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
// DANGER: Reading unverified user data directly from client state
const user = WebApp.initDataUnsafe?.user;
if (user?.id) {
setUserId(user.id);
// Immediately showing the main button allows unauthorized actions
WebApp.MainButton.setText(`Pay Order as User ${user.id}`);
WebApp.MainButton.show();
}
}, []);
return (
<div className="p-4">
<h1>Checkout</h1>
<p>Current Account: {userId ?? 'Loading...'}</p>
</div>
);
}
An attacker does not need access to Telegram source code to exploit this component. They can open your hosted Mini App URL directly in standard desktop browsers (such as Chrome or Firefox), open the Developer Tools Console, and inject the following script before your React component mounts or updates:
// SPOOF PAYLOAD: Executed directly in browser DevTools to hijack account ID 99999999
window.Telegram = {
WebApp: {
initDataUnsafe: {
user: {
id: 99999999,
first_name: "Attacker",
username: "spoofer"
}
},
initData: "user=%7B%22id%22%3A99999999%7D&auth_date=1700000000&hash=fake_hash",
MainButton: {
setText: (text) => console.log("[Spoofed MainButton Text]:", text),
show: () => console.log("[Spoofed MainButton]: Shown to user"),
onClick: (cb) => console.log("[Spoofed MainButton]: Callback attached")
},
ready: () => console.log("[WebApp]: Ready")
}
};
If your backend API processes requests by blindly accepting a userId passed from this frontend state, the attacker successfully impersonates user 99999999. They can execute actions on behalf of victims without ever possessing their private Telegram session keys.
Server-Side Verification: HMAC Validation and Replay Protection
To prevent spoofing, the raw, unparsed string window.Telegram.WebApp.initData must be transmitted to your backend. The backend must independently derive the expected signature using your secret Telegram Bot Token and compare it against the hash query parameter provided in the payload.
Telegram's signature algorithm follows a strict sequence:
1. Parse the query string into key-value pairs. 2. Extract the hash parameter and exclude it from the validation key set. 3. Sort all remaining parameters alphabetically by key. 4. Format sorted parameters into a line-feed-separated string (key=value\nkey2=value2), referred to as the data_check_string. 5. Compute a HMAC-SHA256 signature of your raw Telegram Bot Token using the static key string "WebAppData" as the secret key. This creates the secret derivation key. 6. Compute a second HMAC-SHA256 signature of the data_check_string using the derived secret key. 7. Hex-encode the output and compare it against the received hash parameter using a constant-time comparison algorithm.
In addition to cryptographic hash validation, you must enforce a strict expiration check on auth_date. If an attacker obtains a valid initData string from a legitimate user session via browser history or network logs, they could attempt to replay that string indefinitely. Rejecting payloads older than a set threshold (e.g., 24 hours) mitigates replay attacks.
Here is a complete, production-ready implementation using Node.js and the native crypto module:
// server/auth.js
const crypto = require('crypto');
const BOT_TOKEN = process.env.BOT_TOKEN;
const MAX_AUTH_AGE_SECONDS = 86400; // 24 hours
if (!BOT_TOKEN) {
throw new Error('BOT_TOKEN environment variable is required');
}
/**
* Validates raw Telegram WebApp initData string.
* @param {string} rawInitData - The exact window.Telegram.WebApp.initData string
* @returns {{ valid: boolean, reason?: string, user?: object }}
*/
function verifyInitData(rawInitData) {
if (!rawInitData) {
return { valid: false, reason: 'Empty initData payload' };
}
const params = new URLSearchParams(rawInitData);
const receivedHash = params.get('hash');
if (!receivedHash) {
return { valid: false, reason: 'Missing hash parameter' };
}
// Remove hash parameter before building data_check_string
params.delete('hash');
// Sort keys alphabetically and construct key=value pairs separated by \n
const sortedPairs = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`);
const dataCheckString = sortedPairs.join('\n');
// Step 1: Derive secret key using SHA256 HMAC with "WebAppData"
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(BOT_TOKEN)
.digest();
// Step 2: Compute HMAC-SHA256 signature of data_check_string
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
// Step 3: Constant-time comparison to protect against timing attacks
const calculatedBuffer = Buffer.from(calculatedHash, 'utf8');
const receivedBuffer = Buffer.from(receivedHash, 'utf8');
if (
calculatedBuffer.length !== receivedBuffer.length ||
!crypto.timingSafeEqual(calculatedBuffer, receivedBuffer)
) {
return { valid: false, reason: 'Invalid signature match' };
}
// Step 4: Validate auth_date freshness to prevent replay attacks
const authDateStr = params.get('auth_date');
if (!authDateStr) {
return { valid: false, reason: 'Missing auth_date parameter' };
}
const authDate = parseInt(authDateStr, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (isNaN(authDate) || currentTime - authDate > MAX_AUTH_AGE_SECONDS) {
return { valid: false, reason: 'Authentication payload has expired' };
}
// Parse user JSON string if present
let user = null;
const userJson = params.get('user');
if (userJson) {
try {
user = JSON.parse(userJson);
} catch (e) {
return { valid: false, reason: 'Malformed user JSON payload' };
}
}
return { valid: true, user };
}
module.exports = { verifyInitData };
Using crypto.timingSafeEqual prevents side-channel timing attacks, where an attacker measures the execution time of string comparisons to discover valid signature bytes incrementally.
Secure React Integration: Unlocking MainButton Post-Validation
Your frontend architecture must enforce a single state boundary: client-side features, especially action controls like Telegram.WebApp.MainButton, must remain hidden or disabled until the server returns an explicit authentication response.
When the React application initializes:
1. Call WebApp.ready() immediately so Telegram knows the webview loaded. 2. Keep WebApp.MainButton.hide() executed by default. 3. Read the raw string WebApp.initData (not initDataUnsafe). 4. Send initData in a POST request header or body to your API endpoint. 5. Once the server confirms validity and returns a session or verified user object, enable state-dependent UI and show MainButton with verified action handlers.
Here is the safe React pattern using standard Hooks:
import React, { useEffect, useState, useCallback } from 'react';
import WebApp from '@twa-dev/sdk';
interface VerifiedUser {
id: number;
first_name: string;
username?: string;
}
export function SecureCheckout() {
const [user, setUser] = useState<VerifiedUser | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const handleCheckout = useCallback(async () => {
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `TelegramInitData ${WebApp.initData}`
},
body: JSON.stringify({ item_id: 'prod_12345' })
});
if (!response.ok) {
throw new Error('Checkout transaction failed');
}
WebApp.showAlert('Checkout completed successfully!');
} catch (err) {
WebApp.showAlert((err as Error).message || 'Transaction error');
}
}, []);
useEffect(() => {
// Inform Telegram client the app layout is rendered
WebApp.ready();
// Ensure MainButton is hidden during verification
WebApp.MainButton.hide();
const rawInitData = WebApp.initData;
if (!rawInitData) {
setError('Mini App must be opened within Telegram.');
setIsLoading(false);
return;
}
// Send raw initData to the server for authentication
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: rawInitData })
})
.then((res) => {
if (!res.ok) throw new Error('Authentication failed');
return res.json();
})
.then((data: { user: VerifiedUser }) => {
setUser(data.user);
setIsLoading(false);
// Enable and display MainButton ONLY after successful server response
WebApp.MainButton.setText(`Pay as ${data.user.first_name}`);
WebApp.MainButton.onClick(handleCheckout);
WebApp.MainButton.show();
})
.catch((err) => {
setError((err as Error).message);
setIsLoading(false);
});
return () => {
WebApp.MainButton.offClick(handleCheckout);
WebApp.MainButton.hide();
};
}, [handleCheckout]);
if (isLoading) {
return <div className="p-4 text-center">Authenticating Telegram session...</div>;
}
if (error) {
return (
<div className="p-4 text-red-600 font-semibold">
Access Denied: {error}
</div>
);
}
return (
<div className="p-4 max-w-md mx-auto">
<h1 className="text-xl font-bold mb-2">Secure Checkout</h1>
<p className="text-gray-700">
Welcome, <span className="font-bold">{user?.first_name}</span> (ID: {user?.id})
</p>
</div>
);
}
If an attacker injects fake values into initDataUnsafe or manually triggers script functions in the browser window, the backend /api/auth/verify endpoint returns an HTTP 401 response, halting execution. The code never invokes WebApp.MainButton.show(), leaving the malicious user with an inactive interface and no access to secure API routes.
Production Operational Guidelines
When deploying HMAC-based authentication to production environments, adhere to these operational principles:
- Never leak the Bot Token: The BOT_TOKEN must never be referenced inside React components, Vite/Webpack client bundles, or public build artifacts. It belongs exclusively in server environment variables or isolated secret managers. - Keep auth_date windows tight: While a 24-hour window works for general utility applications, sensitive operations (like account withdrawals or credential changes) should require a fresh initData payload with an auth_date no older than 5 to 15 minutes. - Clear SDK Callbacks on Unmount: In single-page React apps, registered listeners like WebApp.MainButton.onClick persist across component re-renders. Always unbind event listeners using offClick inside your useEffect cleanup return block to prevent duplicate execution loops. - Decouple Auth from Business Logic: Do not pass raw initData on every subsequent API call if your app involves extensive interactive sessions. Exchange verified initData once at /api/auth/verify for an encrypted, short-lived session cookie or JWT, then authenticate subsequent REST or WebSocket requests with that token.
When you need professional help architecting high-concurrency Telegram integrations, turn to BotCreator — a specialized studio that builds secure, production-ready Telegram bots and Mini Apps.