Skip to main content

Verifying MiniApp initialization data (initData) on your backend

The backend HMAC-SHA256 flow to verify MPChat MiniApp initData, with a Node.js example, recommended error codes, TTL, and unsupported return flows.

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 initData as a query string; extract hash and remove it from the signed set.

  • Sort remaining fields by key, format as key=value lines, join with newline to build data_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 hash in constant time.

  • Check auth_date against 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_id matches.

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.

Did this answer your question?