Imagine you are building an order checkout system inside a Telegram Mini App using React. When a user opens your application inside their mobile client, Telegram injects an embedded WebView context exposing the global window.Telegram.WebApp object. React developers often jump straight into building UI components by pulling user profile details directly from window.Telegram.WebApp.initDataUnsafe.user.
If you send this initDataUnsafe user ID directly to your API to look up accounts, process orders, or issue rewards, your application is completely vulnerable to identity spoofing. Because Mini Apps are standard web applications running inside an embedded browser frame, anyone can open your Mini App's URL in a standard desktop browser, open Developer Tools, and mutate window.Telegram.WebApp.initDataUnsafe to contain any target user ID. An attacker can easily impersonate administrators or drain balances belonging to other users.
To secure your React Mini App, you must ignore initDataUnsafe for authorization and treat the raw window.Telegram.WebApp.initData query string as an unverified credential. In this walkthrough, we will cover how to structure your React application lifecycle, perform server-side HMAC-SHA256 validation in Express, and safely control Telegram's native MainButton without exposing unauthorized actions.
Decoding the WebApp Context and Front-End Initialization
When integrating Telegram with React, developers often struggle between using the official script tag or installing third-party wrapper packages like @twa-dev/sdk. While wrapper packages offer TypeScript interfaces, third-party libraries frequently fall behind official Telegram Bot API feature releases. The most reliable approach is referencing the official script in your index.html file while declaring type definitions for window.Telegram.WebApp in your codebase.
Add the official Telegram WebApp script inside your public HTML entry point:
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Mini App</title> <script src="https://telegram.org/js/telegram-web-app.js"></script> </head> <body> <div id="root"></div> </body> </html> Telegram provides two distinct properties on window.Telegram.WebApp:
-
initDataUnsafe: An unverified JavaScript object parsed directly by the client browser. It contains fields likeuser,auth_date,query_id, andhash. Because this object exists in mutable client memory, it provides zero security. -
initData: A raw, URL-encoded string of parameters sent by Telegram's launch context. It contains key-value pairs representing the exact payload before client-side parsing.
An example initData raw string looks like this:
query_id=AAG...&user=%7B%22id%22%3A987654321%2C%22first_name%22%3A%22Alice%22%7D&auth_date=1710000000&hash=a1b2c3d4...
The hash inside this string is an HMAC-SHA256 signature calculated by Telegram using a secret key derived from your Telegram Bot Token. The front-end cannot alter any value inside initData without breaking the hash. Your React application must pass this raw string to your API server inside an HTTP header (such as Authorization: Bearer <initData>) on every initial load.
// src/services/api.ts export async function verifyAndFetchUserSession(rawInitData: string) { const response = await fetch('/api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `TelegramInitData ${rawInitData}` } }); if (!response.ok) { throw new Error('Authentication failed: Invalid Telegram signature'); } return response.json(); } Implementing Server-Side HMAC SHA-256 Validation in Express
To verify that the payload originated from Telegram and was not altered by a proxy or client console script, your backend must perform the cryptographic verification procedure specified in the Telegram Bot API documentation.
The algorithm requires these steps:
- Parse the raw URL-encoded
initDatastring into key-value pairs. - Extract and remove the
hashkey from the key-value pairs. - Sort all remaining key-value pairs alphabetically by their keys.
- Format the sorted key-value pairs into a newline-separated string in the format
key=value\nkey=value. Telegram designates this string as thedata_check_string. - Compute a secret key by taking the HMAC-SHA256 hash of your Bot Token string, using the constant string
"WebAppData"as the HMAC key. - Compute the HMAC-SHA256 hash of the
data_check_stringusing the generated secret key. - Compare the resulting hex digest against the client's
hashparameter using a constant-time string comparison function to prevent timing attacks. - Verify the
auth_dateparameter to reject stale initialization strings. Ifauth_dateis older than your expiration threshold (e.g., 86,400 seconds or 24 hours), reject the request to prevent replay attacks.
Here is a complete Express authentication middleware implementing raw string verification using Node.js's built-in crypto module:
// server/middleware/verifyTelegram.js const crypto = require('crypto'); function validateTelegramInitData(req, res, next) { const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('TelegramInitData ')) { return res.status(401).json({ error: 'Missing or malformed Authorization header' }); } const rawInitData = authHeader.substring('TelegramInitData '.length); const urlParams = new URLSearchParams(rawInitData); const receivedHash = urlParams.get('hash'); if (!receivedHash) { return res.status(400).json({ error: 'Missing hash parameter in initData' }); } // Remove hash parameter before sorting urlParams.delete('hash'); // Sort keys alphabetically and format into data_check_string const paramsArray = Array.from(urlParams.entries()); paramsArray.sort(([aKey], [bKey]) => aKey.localeCompare(bKey)); const dataCheckString = paramsArray.map(([key, value]) => `${key}=${value}`).join('\n'); const botToken = process.env.BOT_TOKEN; if (!botToken) { throw new Error('BOT_TOKEN environment variable is not defined'); } // Compute HMAC secret key derived from Bot Token using constant seed "WebAppData" const secretKey = crypto .createHmac('sha256', 'WebAppData') .update(botToken) .digest(); // Compute HMAC hash of data_check_string const calculatedHash = crypto .createHmac('sha256', secretKey) .update(dataCheckString) .digest('hex'); // Perform constant-time equality check const hashBuffer = Buffer.from(receivedHash, 'utf-8'); const calculatedBuffer = Buffer.from(calculatedHash, 'utf-8'); if (hashBuffer.length !== calculatedBuffer.length || !crypto.timingSafeEqual(hashBuffer, calculatedBuffer)) { return res.status(403).json({ error: 'Invalid cryptographic signature' }); } // Check auth_date for replay attack prevention (max age: 24 hours) const authDate = parseInt(urlParams.get('auth_date') || '0', 10); const currentTime = Math.floor(Date.now() / 1000); const maxAge = 86400; if (!authDate || (currentTime - authDate) > maxAge) { return res.status(403).json({ error: 'InitData payload has expired' }); } // Parse verified user payload and attach to request const userJson = urlParams.get('user'); req.telegramUser = userJson ? JSON.parse(userJson) : null; return next(); } module.exports = { validateTelegramInitData }; Safe React Lifecycle and MainButton Integration
Telegram's native MainButton renders inside the mobile client container outside your React DOM tree. Because MainButton is controlled imperatively via global WebApp methods (window.Telegram.WebApp.MainButton), improperly managing its state inside React components leads to broken event handlers and double-click actions.
Two critical implementation mistakes often break React Mini Apps:
- Showing or enabling the
MainButtonbefore backend initialization finishes. - Attaching
MainButton.onClickevent listeners without clearing them in React'suseEffectcleanup return function, causing handlers to accumulate on every component re-render.
The proper architecture requires synchronizing backend authentication status with React state, initializing Telegram.WebApp.ready(), and safely handling MainButton click registration.
Here is a complete checkout component demonstrating safe backend authentication and MainButton binding:
// src/components/CheckoutApp.tsx import React, { useEffect, useState, useCallback } from 'react'; import { verifyAndFetchUserSession } from '../services/api'; interface UserProfile { id: number; first_name: string; username?: string; } export const CheckoutApp: React.FC = () => { const [user, setUser] = useState<UserProfile | null>(null); const [isLoading, setIsLoading] = useState<boolean>(true); const [error, setError] = useState<string | null>(null); const [orderProcessed, setOrderProcessed] = useState<boolean>(false); const handleMainButtonClick = useCallback(async () => { const webApp = window.Telegram?.WebApp; if (!webApp) return; webApp.MainButton.showProgress(false); try { const response = await fetch('/api/orders/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `TelegramInitData ${webApp.initData}` }, body: JSON.stringify({ itemSKU: 'PREMIUM_SUBSCRIPTION' }) }); if (!response.ok) throw new Error('Order creation failed'); setOrderProcessed(true); webApp.MainButton.setText('ORDER COMPLETE'); webApp.MainButton.disable(); } catch (err) { webApp.showAlert('Failed to process order. Please try again.'); } finally { webApp.MainButton.hideProgress(); } }, []); useEffect(() => { const webApp = window.Telegram?.WebApp; if (!webApp) { setError('Telegram WebApp context not found'); setIsLoading(false); return; } // Signal to Telegram container that interface is ready to render webApp.ready(); webApp.expand(); const rawInitData = webApp.initData; if (!rawInitData) { setError('No initialization data provided'); setIsLoading(false); return; } // Validate raw initData against Express backend before unlocking UI verifyAndFetchUserSession(rawInitData) .then((data) => { setUser(data.user); setIsLoading(false); }) .catch((err) => { setError(err.message); setIsLoading(false); }); }, []); useEffect(() => { const webApp = window.Telegram?.WebApp; if (!webApp || isLoading || error || !user || orderProcessed) return; const mainButton = webApp.MainButton; mainButton.setText('CONFIRM AND PAY'); mainButton.enable(); mainButton.show(); // Register click event handler mainButton.onClick(handleMainButtonClick); // Clean up event handler on component unmount or state update return () => { mainButton.offClick(handleMainButtonClick); mainButton.hide(); }; }, [isLoading, error, user, orderProcessed, handleMainButtonClick]); if (isLoading) { return <div style={{ padding: '20px', textAlign: 'center' }}>Authenticating with Telegram...</div>; } if (error) { return <div style={{ padding: '20px', color: 'red' }}>Error: {error}</div>; } return ( <div style={{ padding: '16px', fontFamily: 'sans-serif' }}> <h2>Welcome, {user?.first_name}</h2> {orderProcessed ? ( <p>Your order has been confirmed successfully!</p> ) : ( <p>Item: Premium Subscription — Click the button below to purchase.</p> )} </div> ); }; Production Notes and Edge Cases
Deploying Telegram Mini Apps in production requires strict operational safeguards around WebApp execution:
- Never Bundle Bot Tokens: Never expose your
BOT_TOKENin React client-side environment variables (REACT_APP_BOT_TOKENorVITE_BOT_TOKEN). HMAC generation must solely occur inside server memory. - Handling Replay Protection Windows: Keep your
auth_datevalidation window strictly bounded. A standard expiration limit of 86,400 seconds (24 hours) protects against stale links being reused while allowing reasonable session lifetime for returning users. - Managing Viewport Heights: Mobile web view containers vary between iOS and Android Telegram clients. Call
Telegram.WebApp.expand()immediately upon component mounting to prevent unexpected vertical scrolling and clipping inside bottom navigation bars. - Clean Up MainButton Callbacks: Neglecting to invoke
MainButton.offClick(handler)inside React unmount hooks causes registered listeners to stack in client memory. If a user triggers a route transition inside React, subsequent button presses will invoke unmounted component callbacks simultaneously.
Building high-performance Telegram Mini Apps requires careful separation between client UI components and backend signature validation. If you are launching complex Mini Apps, high-volume payment flows, or custom client integrations, consider working with BotCreator — a specialized development studio that ships production-ready Telegram bots and Mini App platforms.
