Secure Telegram Payments with HMAC-Verified initData in PHP
When you accept money through a Telegram bot, the most common security hole is treating initData from the WebApp as untrusted input. An attacker can craft a malicious payload that looks legitimate but doesn't match your bot's expected signature. This lets them trigger purchases, drain accounts, or create fake orders.
In this guide you'll build a full payment pipeline that:
-
Validates the incoming
initDatausing a per-message HMAC signed with your bot token. -
Rejects requests with stale
auth_date, unknownbot_id, or mismatched signatures. -
Handles the webhook securely with idempotency (stored
update_idto dedupe retries). -
Processes the
successful_paymentevent and confirms the transaction before releasing funds.
If you skip any of these checks, an attacker can spoof a payment, bypass rate limits, or create duplicate charges. The rest of the walkthrough shows exactly what happens when each layer fails.
1. The Payment Flow Overview
A typical Telegram payment flow consists of four stages:
-
Setup – The user starts the purchase (
/start). Your bot returns at.me/Bot?start=deep link containing a uniquerequest_idand a challenge string. -
Pre‑checkout query – Before charging, the bot sends a
pre_checkout_queryto verify the user has approved payment and hasn't been double‑charged. -
Payment – The user pays via a Telegram Mini App (or external gateway). Upon success, the bot receives
successful_payment. - Confirmation – The bot marks the order as paid and may trigger side effects (shipping, notification).
The critical security gap appears between stages 1 and 3. Many developers assume the initData sent during /start is immutable and trusted. It isn't. An attacker who can read the bot's source (or reverse‑engineer it) can craft a new initData that matches their own parameters, effectively impersonating the user.
To close this gap, every request that carries initData must be verified against a fresh HMAC computed over the payload using the bot's secret token. Only responses that pass this check proceed to the next stage.
2. Server‑Side HMAC Verification of initData
The core of the defense is a simple HMAC algorithm. For each message that includes initData, compute:
$secret = getenv('TELEGRAM_BOT_SECRET'); // 32‑byte random string
$message = $event['text'] . ':' . $event['init_data']; // canonical representation
$expected = hash_hmac('sha256', $message, $secret);
If $expected !== $event['init_data'], reject immediately with 401 Unauthorized. This ensures that even if an attacker modifies the initData in transit, the mismatch is caught.
Why HMAC matters beyond basic signing
- Integrity – Any tampering changes the HMAC, so the server detects modification.
- Authenticity – Because the secret lives only on your server, only you can generate valid signatures.
-
Binding – You can also embed the
bot_idinside the message and verify it alongside the HMAC, making it impossible to swap the identity of the caller.
Below is a production‑ready helper that performs both HMAC verification and bot_id validation:
<?php
/**
* Verify initData integrity and bot identity.
*
* @param array $event The raw Telegram event (includes 'text' and 'init_data').
* @return bool True if the event is valid and authorized.
*/
function verifyInitData(array $event): bool {
// 1. Check auth_date is not expired (e.g., 24 hours)
$authDate = (int) $event['auth_date'] ?? 0;
$maxAge = time() - (86400 * 24); // 24 hours
if ($authDate < $maxAge) {
return false;
}
// 2. Extract the canonical message used for HMAC
$canonical = $event['text'] . ':' . $event['init_data'];
// 3. Compute expected HMAC
$secret = getenv('TELEGRAM_BOT_SECRET');
if (!hash_hmac('sha256', $canonical, $secret)) {
return false;
}
// 4. Optionally bind bot_id (useful for preventing cross‑account attacks)
$botId = $event['bot_id'] ?? '';
if ($botId !== getenv('TELEGRAM_BOT_ID')) {
return false;
}
return true;
}
This function should be called before any payment logic. If it returns false, abort the request entirely. The cost is negligible—one HMAC computation per event—and it stops spoofed payments cold.
3. Webhook Handler with Idempotency
Telegram delivers events via webhook. The handler must be robust against:
- Retries – Telegram may resend the same event (especially during network glitches).
- Out‑of‑order delivery – Events might arrive slightly out of sequence.
- Duplicate processing – Without idempotency, a successful payment could be charged twice.
We solve this by storing the update_id (a unique identifier generated when the user started the purchase) and checking it before processing. If the ID was already handled, we return the cached response instead of reprocessing.
<?php
/**
* Process a Telegram webhook event with idempotency protection.
*
* @param array $event The raw webhook payload.
* @param int $update_id The unique ID from the /start response.
* @return mixed Response or null (if already processed).
*/
function processWebhook(array $event, int $update_id): mixed {
// 1. Quick HMAC check (same as above, but we already did it earlier)
if (!verifyInitData($event)) {
http_response_code(401);
exit();
}
// 2. Idempotency lookup
$processed = [];
foreach ((array) $processedEvents as $key => $record) {
if ($key === $update_id) {
// Already handled – return the stored response
return $record['response'];
}
}
// 3. Handle the specific event type
switch ($event['type']) {
case 'pre_checkout_query':
// Verify user consent before proceeding
if (!$event['query']['answer']) {
http_response_code(400);
exit();
}
break;
case 'successful_payment':
// Mark as processed before doing anything else
$processed[] = [
'update_id' => $update_id,
'status' => 'success',
'payment_id' => $event['from']['id'], // optional reference
];
// Now perform the actual payment confirmation
processPayment($event);
break;
default:
// Unknown event – ignore or log for debugging
break;
}
// 4. Persist the result for future idempotency checks
$processedEvents[$update_id] = (
trim)json_encode([
'update_id' => $update_id,
'status' => 'handled',
'timestamp' => time(),
]);
http_response_code(200);
return null;
}
Note the use of an associative array $processedEvents (in production, store this in Redis or a database table). The key is the update_id returned during the initial /start phase. By checking this before any side effect, you guarantee that even if Telegram retries the same webhook, the system won't charge the user twice.
4. Typical Errors & How to Fix Them
| Error | Symptom | Root Cause | Fix |
|---|---|---|---|
| Spoofed initData | User triggers a payment without actually approving it | Missing HMAC verification | Always run verifyInitData() before any payment logic. |
| Stale auth_date | Old sessions still accepted | No expiry check | Compare auth_date against a configurable window (e.g., 24h) and reject older values. |
| Cross‑account fraud | Two users share the same bot token | Bot token exposed publicly | Keep the token in a file with restricted permissions and never commit it. Use environment variables. |
| Idempotency gap | Double charges after network retry | No update_id tracking |
Store every update_id in a persistent store and check before processing. |
| Large initData truncation | Failed HMAC due to extra whitespace | Improper canonicalization | Normalize the message (remove leading/trailing spaces, encode special characters) before computing the HMAC. |
Example Failure Scenario
An attacker discovers your bot's source code and crafts a new /start payload with a modified init_data that passes the HMAC because they know your secret (or they guess it). They then send pre_checkout_query repeatedly until the bank account is drained. With the HMAC gate in place, the attacker's modifications will fail verification, and the webhook handler will reject each attempt instantly.
Another common mistake is sending successful_payment without verifying the from field. If the attacker can forge an event with a different from ID, they might mark a pending payment as completed. The fix is to validate that the from (usually a phone number or user ID) matches the expected participant before updating the order status.
5. Putting It All Together – End‑to‑End Flow
Here is the minimal set of files you need to wire together:
-
verification.php– ContainsverifyInitData()and any shared constants. -
webhook.php– The entry point that receives Telegram webhooks, callsverifyInitData(), then delegates toprocessWebhook(). -
payment.php– Handlespre_checkout_queryandsuccessful_paymentsub‑flows. -
Database – A table
webhook_processedwith columnsupdate_id,status,created_atfor idempotency.
Start with the webhook handler, which does the HMAC check and idempotency lookup. Then add the pre_checkout_query endpoint that reads the current state and asks the user to confirm. Finally, implement successful_payment which marks the order as paid and optionally triggers downstream actions.
Each piece is independent but must cooperate. Skip any layer, and the chain becomes vulnerable. The HMAC validation is the front door; idempotency is the lock on the storage room; the state machine guards the payment itself.
By following this pattern, you eliminate the most common Telegram payment vulnerabilities while keeping the code simple enough to maintain and audit. Remember: never trust initData blindly. Treat it as a claim that must be proven with cryptographic evidence on every round trip.
For more on building secure Telegram bots and Mini Apps, see BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)