Verify before you trust. Compute an HMAC-SHA256 over the sorted fields, compare in constant time, then check auth_date freshness. Only then read user, chat, or miniapp_id.
Overview
The frontend sends the raw initData string. Your backend must verify its signature with the bot token before using any field. User information comes from verified initData.user; MPChat does not issue platform access tokens in this phase.
Verification steps
Parse
initDataas a query string; extracthashand remove it from the signed set.Sort remaining fields by key, format as
key=valuelines, join with newline to builddata_check_string.Secret key = HMAC-SHA256(key=
"WebAppData", message=bot token).Expected hash = HMAC-SHA256(key=secret, message=
data_check_string).Compare expected and received
hashin constant time.Check
auth_dateagainst your service's freshness policy. This example accepts data up to 300 seconds old and allows 30 seconds of future clock skew.If binding a request to one MiniApp, verify the signed
miniapp_idmatches.
Node.js example
Use the bot token and expected MiniApp ID from your server configuration. Reject duplicate fields, verify the signature over every decoded field except hash, and parse user only after the signature and time checks. A fresh signature can still be replayed, so deduplicate any one-time business action separately.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyInitData(initData, botToken, { miniappId } = {}) {
if (typeof initData !== "string" || !initData || !botToken) {
throw new Error("INIT_DATA_INVALID");
}
const params = new URLSearchParams(initData);
if (new Set(params.keys()).size !== params.size) {
throw new Error("INIT_DATA_INVALID");
}
const hash = params.get("hash");
const authDateValue = params.get("auth_date");
if (!/^[a-f0-9]{64}$/i.test(hash ?? "") || !/^\d+$/.test(authDateValue ?? "")) {
throw new Error("INIT_DATA_INVALID");
}
params.delete("hash");
params.sort();
const checkString = [...params].map(([key, value]) => key + "=" + value).join("\n");
const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
const expected = createHmac("sha256", secret).update(checkString).digest();
if (!timingSafeEqual(Buffer.from(hash, "hex"), expected)) {
throw new Error("INIT_DATA_INVALID");
}
const authDate = Number(authDateValue);
const now = Math.floor(Date.now() / 1000);
const ttlSeconds = 300;
const futureSkewSeconds = 30;
if (!Number.isSafeInteger(authDate) || authDate < now - ttlSeconds ||
authDate > now + futureSkewSeconds) {
throw new Error("INIT_DATA_INVALID");
}
if (miniappId && params.get("miniapp_id") !== miniappId) {
throw new Error("MINIAPP_FORBIDDEN");
}
let user;
try {
user = JSON.parse(params.get("user"));
} catch {
throw new Error("INIT_DATA_INVALID");
}
if (!user || typeof user !== "object" || Array.isArray(user) || user.id == null) {
throw new Error("INIT_DATA_INVALID");
}
if (!(Number.isSafeInteger(user.id) && user.id >= 0) &&
!(typeof user.id === "string" && /^[0-9]+$/.test(user.id))) {
throw new Error("INIT_DATA_INVALID");
}
return user;
}
Suggested application error codes
Scenario | Code |
initData missing/malformed/tampered/expired | INIT_DATA_INVALID |
MiniApp does not exist | MINIAPP_NOT_FOUND |
MiniApp disabled | MINIAPP_DISABLED |
miniapp_id not owned by expected bot/context | MINIAPP_FORBIDDEN |
Not supported yet
Full client return flows are not available in the current phase: web_app_data, sendData, answerWebAppQuery, and platform access-token exchange. Your backend does not call a platform Bearer-token endpoint to get user info.
Related
Error responses and logs must never include the bot token, full initData, database IDs, or stack traces.
