Skip to content

Validating initData

When GROM opens a Mini App it hands the page a signed description of the launch: who opened it, from where, and when. That string is the app's entire authentication story. Verifying it is not a hardening step you add later — it is the step that decides whether your app has authentication at all.

Not implemented

Mini Apps do not run yet — there is no client runtime and no SDK to include. See implementation status.

Nothing here has been run against a live client. The algorithm is specified in the TZ and reproduced faithfully; the code is reference, not something that has been observed to work end to end.

What an unverified launch context is worth

Nothing. Understand this before reading the algorithm, because it explains why the rest of the page is so pedantic.

A Mini App URL is a normal URL. Anyone can open it in a browser, with any fragment they choose. The launch context is a string the client hands to a page — it is not delivered over an authenticated channel, it is not bound to a session, and the platform does not sit between your page and your backend to vouch for it.

So if your backend accepts a launch context without checking the signature, or checks it in the browser, or trusts a user.id the page parsed for itself, then anyone can impersonate any user of your app by typing a different number. Not a user they know the password of. Any user. Admin accounts included, if your admin check reads that id.

There is no rate limit, no anomaly detection and no audit trail that saves you from this. The signature is the control.

Three rules, no exceptions

  1. Verify on your server. The HMAC key is your bot token; a browser that can verify is a browser that has your token, and a leaked token is total compromise of the bot.
  2. Never authorize on a client-supplied identity. A parsed-but-unverified copy of the context — Telegram's model calls this initDataUnsafe, and the name is doing real work — is fine for painting a name on screen before the round trip completes. It is not fine for deciding what that person may do.
  3. Bind the result to your own session. Verify once, issue your own cookie or token, and authorize on that.

What arrives

The context is delivered in the URL fragment, as #tgWebAppData=…. The TZ makes this mandatory: fragments are not transmitted to servers, so the signed blob stays out of access logs, proxy logs and Referer headers.

The consequence is that your backend cannot read it from the request. The page must read location.hash and send it deliberately — see the quickstart.

Decoded, the value is a URL-encoded query string. The TZ names query_id, auth_date, hash, signature and "basic user information" explicitly; the fuller field set below follows the Telegram Web Apps model, which GROM tracks where the TZ is silent.

FieldMeaning
userJSON object describing the user who opened the app
auth_dateUnix timestamp of the launch. Load-bearing — see below
hashHex HMAC-SHA256 over every other field
signatureEd25519 signature, for verification without the bot token
query_idPresent for inline-button and menu-button launches; spend it with answerWebAppQuery
start_paramThe startapp value from a deep link, 1–64 chars of A-Za-z0-9_-
chat_type, chat_instanceWhere the app was opened from
receiver, chatThe other party, for launches inside a chat

Treat unknown fields as data to be included in the check string, not as fields to be dropped. The signature covers everything; a verifier that only knows about the fields it recognizes will start failing the day a new one is added.

The algorithm

Exactly as specified:

secret_key   = HMAC_SHA256(key="WebAppData", data=bot_token)
check_string = every field as "key=value",
               sorted alphabetically by key,
               joined with "\n",
               excluding the hash field itself
hash         = hex( HMAC_SHA256(key=secret_key, data=check_string) )

Two details in the first line trip people up. The literal string WebAppData is the key and the bot token is the data — not the other way round. And the result is used as raw bytes, not as a hex string, for the second HMAC.

The rest is where implementations actually go wrong:

  • Exclude hash itself. It cannot sign itself. Remove it before building the string, not after.
  • Sort by key, not by the joined pair. For most inputs these agree, which is what makes the bug survive testing. They diverge as soon as one key is a prefix of another.
  • Use decoded values. Parse the query string once, and build key=value from the decoded values. Re-encoding, double-decoding, or building the string from the raw slice all produce a different digest and a rejection you will spend an afternoon on.
  • Join with a real newline. "\n", one byte. Not \r\n, not a platform-dependent separator.
  • Do not normalize the JSON. user is a JSON string in the middle of the check string. It is signed as the exact bytes that arrived. Parse it after verifying, never before.

Specification gap

The TZ says the check string excludes hash. It does not say whether signature is also excluded when both fields are present. The Ed25519 and HMAC schemes were specified separately and the interaction between them was never written down.

That ambiguity is an interoperability failure waiting to happen: two correct-looking implementations that disagree on one field will reject each other's launches with no useful error. It needs a decision before anything ships. This page will not guess which way it goes.

Verifying

python
import hmac, hashlib, json, time
from urllib.parse import parse_qsl

MAX_AGE = 3600  # seconds; see "Freshness" below

def verify(init_data: str, bot_token: str) -> dict:
    # strict_parsing rejects empty or malformed input instead of quietly
    # returning {} — which would otherwise verify as an empty check string.
    pairs = dict(parse_qsl(init_data, strict_parsing=True))

    received = pairs.pop("hash", None)
    if not received:
        raise ValueError("no hash")

    check_string = "\n".join(f"{k}={pairs[k]}" for k in sorted(pairs))

    secret = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
    expected = hmac.new(secret, check_string.encode(), hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, received):
        raise ValueError("bad signature")

    auth_date = int(pairs.get("auth_date", 0))
    if time.time() - auth_date > MAX_AGE:
        raise ValueError("stale launch")

    return json.loads(pairs["user"])  # parsed only after the signature holds
php
<?php
const MAX_AGE = 3600;

function verify(string $initData, string $botToken): array
{
    parse_str($initData, $pairs);

    $received = $pairs['hash'] ?? '';
    if ($received === '') {
        throw new RuntimeException('no hash');
    }
    unset($pairs['hash']);

    ksort($pairs); // by key, and SORT_REGULAR is correct for these keys

    $parts = [];
    foreach ($pairs as $k => $v) {
        $parts[] = $k . '=' . $v;
    }
    $checkString = implode("\n", $parts);

    // 4th arg true => raw bytes, which is what the second HMAC needs.
    $secret   = hash_hmac('sha256', $botToken, 'WebAppData', true);
    $expected = hash_hmac('sha256', $checkString, $secret);

    if (!hash_equals($expected, $received)) {
        throw new RuntimeException('bad signature');
    }
    if (time() - (int) ($pairs['auth_date'] ?? 0) > MAX_AGE) {
        throw new RuntimeException('stale launch');
    }

    return json_decode($pairs['user'], true, 512, JSON_THROW_ON_ERROR);
}
javascript
import crypto from 'node:crypto'

const MAX_AGE = 3600

export function verify(initData, botToken) {
  const params = new URLSearchParams(initData)

  const received = params.get('hash')
  if (!received) throw new Error('no hash')
  params.delete('hash')

  const checkString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) // by key; never localeCompare
    .map(([k, v]) => `${k}=${v}`)
    .join('\n')

  const secret = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest()
  const expected = crypto.createHmac('sha256', secret).update(checkString).digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(received, 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('bad signature')

  if (Date.now() / 1000 - Number(params.get('auth_date') ?? 0) > MAX_AGE) {
    throw new Error('stale launch')
  }

  return JSON.parse(params.get('user'))
}

Three things those share, deliberately.

Constant-time comparison. compare_digest, hash_equals, timingSafeEqual — never ==. A byte-by-byte comparison that returns early leaks how much of a guessed digest was right, and that is enough to forge one given patience. It costs one function name to avoid.

Length checked before comparing. timingSafeEqual throws on mismatched lengths, so the Node version checks first. Letting that throw into a generic 500 is a subtler bug than it looks: it turns a forgery attempt into a different response than a wrong-digest attempt.

Parsing after verifying. user is parsed only once the digest holds. Parsing attacker-controlled JSON before authenticating it hands an attacker your parser.

Freshness and replay

The TZ requires rejecting a launch whose auth_date is more than 24 hours old. That is the outer bound, not a recommendation.

A launch context is a bearer credential. Anyone who obtains one — from a screenshot, a shared URL, a debug log, an error report that captured the fragment — can replay it until it expires. Twenty-four hours is a long window for that.

Use a tight window for the one call that establishes a session: the examples above use an hour, and minutes is defensible when you control both ends. Then stop using the context entirely and authorize on your own session, which you can revoke.

If you must accept launches over a long window, track them. Store a digest of each accepted hash with its expiry and reject repeats; that turns a replayable credential into a single-use one. Clock skew cuts both ways, so reject an auth_date meaningfully in the future too — otherwise a forged-looking timestamp buys an attacker an arbitrarily long window.

query_id has its own lifetime: the client re-arms it roughly every 60 seconds while the app is open, and it is spent once by answerWebAppQuery. It is not a session identifier and should not be stored as one.

Verifying without the bot token

The TZ specifies a second scheme: an Ed25519 signature field, so a third party can verify a launch without holding the bot token. That matters when a Mini App is operated by someone other than the bot's owner, or when verification happens in a service you do not want to hand a token to.

What the TZ does not specify: where the public key comes from, how it is rotated, how the key is bound to a bot id, or the exact check-string construction for this scheme. Those are not small details — they are the whole trust model. Until they are written down, the Ed25519 path is a named intention rather than something you can implement.

The HMAC scheme is the one that is fully specified. Use it.

Checklist

  • [ ] Verification happens server-side; the bot token never reaches the browser
  • [ ] hash removed before building the check string
  • [ ] Fields sorted by key, joined with \n, values URL-decoded exactly once
  • [ ] Unknown fields included, not dropped
  • [ ] HMAC(key="WebAppData", data=token) — that order — used as raw bytes
  • [ ] Constant-time comparison, with lengths checked first
  • [ ] auth_date window enforced, well under the 24-hour maximum
  • [ ] Future-dated auth_date rejected
  • [ ] user JSON parsed only after the signature holds
  • [ ] Result bound to your own session; nothing authorizes on initDataUnsafe
  • [ ] Negative tests exist: tampered field, missing hash, stale date, empty string