· 5 min read

Telegram Mini Apps: A Common Mistake That Breaks Your Init Data Validation

One of the easiest ways to ship a vulnerable Telegram Mini App is to validate `initData` incorrectly. This guide shows the mistake developers make most often, why it fails in real apps, and how to implement validation safely.

Telegram Mini Apps are powerful, but their security model is easy to misunderstand. The most common mistake I see is treating initDataUnsafe as trusted user data or validating initData with the wrong hashing procedure.

If your app uses Telegram login state, user identity, or any server-side authorization, this mistake can let attackers impersonate users, tamper with payload values, or bypass checks entirely.

The mistake: trusting initDataUnsafe

When a Mini App opens, Telegram provides initialization data to your client app. Developers often do this:

const { user, query_id } = window.Telegram.WebApp.initDataUnsafe;

Then they immediately send user to the backend and assume it is valid because it came from Telegram.

That is the mistake.

initDataUnsafe is a convenience object for the frontend. The name is literal: it is unsafe. It should never be treated as proof of identity on its own.

A malicious client can modify browser state, inject JavaScript, or replay data outside the Telegram context. Your backend must verify the signed initData string before trusting any fields inside it.

What Telegram actually gives you

Telegram Mini Apps expose:

  • initData: a signed query-string payload
  • initDataUnsafe: a parsed client-side object

The secure flow is:

  1. Read initData from the client.
  2. Send the raw string to your backend.
  3. Validate the signature on the server.
  4. Only then trust the embedded user or chat fields.

Do not validate fields after they are parsed and normalized by your own code unless you are careful to preserve Telegram’s exact canonicalization rules.

Why validation fails in real apps

There are a few recurring bugs:

1. Hashing the wrong string

Telegram requires a very specific validation process:

  • Parse the query string
  • Remove the hash field
  • Sort the remaining key-value pairs alphabetically by key
  • Join them as key=value lines separated by \n
  • Derive the secret key from your bot token
  • Compute HMAC-SHA256 over the data-check string
  • Compare the result to the provided hash

A common bug is hashing the raw query string directly, or sorting after decoding values incorrectly.

2. Using the wrong secret key derivation

For Mini Apps, the secret key is not your bot token itself. It is derived using:

secret_key = HMAC_SHA256(key = "WebAppData", message = bot_token)

Then the validation hash is computed with that derived key.

If you skip this step and use the bot token directly, valid requests will fail, and some developers end up weakening checks until “it works.” That is dangerous.

3. Re-encoding or decoding values too early

Telegram signs the exact field values as they appear in the query string. If your server framework automatically decodes, normalizes Unicode, changes ordering, or rewrites spaces, your computed hash may differ from Telegram’s.

This often shows up when developers use helpers that parse into objects and then reconstruct the string from the object.

4. Trusting client-side role or permission flags

Some apps include custom logic like:

  • is_premium
  • is_admin
  • can_access_beta

If those values are derived from the frontend state rather than a validated Telegram identity and your own server-side authorization rules, they are not trustworthy.

Telegram validates identity, not your app’s business permissions.

A correct server-side validation pattern

Here is the structure you want on the backend.

Node.js example

import crypto from 'crypto';

function validateTelegramInitData(initData, botToken) {
  const params = new URLSearchParams(initData);
  const receivedHash = params.get('hash');
  if (!receivedHash) return false;

  params.delete('hash');

  const dataCheckString = [...params.entries()]
    .sort(([aKey], [bKey]) => aKey.localeCompare(bKey))
    .map(([key, value]) => `${key}=${value}`)
    .join('\n');

  const secretKey = crypto
    .createHmac('sha256', 'WebAppData')
    .update(botToken)
    .digest();

  const computedHash = crypto
    .createHmac('sha256', secretKey)
    .update(dataCheckString)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(computedHash, 'hex'),
    Buffer.from(receivedHash, 'hex')
  );
}

Important notes

  • Validate the raw initData string received from the client.
  • Use a constant-time comparison when checking the hash.
  • Reject requests with missing or malformed fields.
  • Do not accept identity claims unless validation succeeds.

A safer request flow

A practical implementation looks like this:

Client

const initData = window.Telegram.WebApp.initData;

await fetch('/api/session/telegram', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ initData })
});

Server

  1. Receive initData.
  2. Validate signature.
  3. Extract user object from the validated payload.
  4. Create your own session cookie or JWT.
  5. Use that session for app access.

This avoids repeatedly re-validating Telegram data on every request while still preserving trust boundaries.

Common anti-patterns to avoid

Anti-pattern 1: trusting the frontend user ID

// Bad
const userId = window.Telegram.WebApp.initDataUnsafe.user.id;
await api.post('/api/profile', { userId });

The server should determine the user from validated init data or a server-issued session, not from a client-supplied ID.

Anti-pattern 2: validating only on the client

Client-side checks are useful for UX, but they do not secure your backend. If the backend accepts the request without validation, the app is still vulnerable.

Anti-pattern 3: parsing once and reusing forever

Telegram initData is not a long-lived credential. Treat it as an authentication bootstrap, not a permanent login token. If you need a durable session, mint your own after validation.

Anti-pattern 4: skipping timestamp checks

Telegram payloads include authentication-related metadata. If your implementation supports freshness validation, enforce it. This reduces replay risk if a signed payload is captured and reused later.

Debugging validation failures

If your hash checks keep failing, verify these in order:

  1. Are you using the raw initData string?
  2. Did you remove the hash field before building the check string?
  3. Are you sorting keys exactly once, alphabetically?
  4. Are you using the bot token only in the secret-key derivation step?
  5. Are you preserving the exact field values without unwanted normalization?
  6. Are you comparing hex values correctly?

A quick way to isolate the bug is to log the intermediate dataCheckString and compare it against a known-good implementation.

Practical security guidance for production apps

For Telegram Mini Apps in production:

  • Validate initData on the server for every new session bootstrap.
  • Issue your own application session after successful validation.
  • Bind sensitive actions to the server-side session, not to raw Telegram payloads.
  • Reject requests with stale or malformed init data.
  • Keep bot tokens secret and rotate them if exposed.

Bottom line

The biggest mistake in Telegram Mini Apps is assuming the client’s initDataUnsafe is trustworthy. It is not. Your backend must validate the raw initData string with Telegram’s exact HMAC procedure before using any identity fields.

If you get that one detail right, most of the platform’s security model becomes much easier to reason about. If you get it wrong, everything built on top of it is shaky.

For Telegram developers, this is one of those details that is easy to overlook and expensive to fix later. Treat initData validation as a hard security boundary, not a formality.

← All posts