Verify launch data

Diil.initData is a query string:

app_id=21&auth_date=1791083707&user=%7B%22id%22%3A4%2C%22firstName%22%3A%22Anna%22%2C…%7D&hash=5f0c…

How to verify #

  1. Parse it. Take hash out.
  2. Sort the remaining fields by key and join them as key=value lines with \n, using the decoded values. That is the data_check_string.
  3. secret_key = HMAC_SHA256(key = "DiilMiniApp", message = client_secret).
  4. hash must equal hex(HMAC_SHA256(key = secret_key, message = data_check_string)). Compare in constant time.
  5. Check that app_id is yours and that auth_date (Unix seconds) is recent. Reject anything older than 24 hours. Shorter is fine.

Only then trust user. Never trust Diil.initDataUnsafe (the parsed copy) without this check.

Node.js #

const crypto = require('crypto')

function verifyInitData(initData, clientSecret, appId, maxAgeSec = 86400) {
  const params = new URLSearchParams(initData)
  const hash = params.get('hash')
  params.delete('hash')
  const dataCheckString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, v]) => `${k}=${v}`).join('\n')
  const secretKey = crypto.createHmac('sha256', 'DiilMiniApp').update(clientSecret).digest()
  const expected = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
  if (!hash || hash.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expected))) return null
  if (params.get('app_id') !== String(appId)) return null
  if (Date.now() / 1000 - Number(params.get('auth_date')) > maxAgeSec) return null
  return JSON.parse(params.get('user'))
}

Python #

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

def verify_init_data(init_data: str, client_secret: str, app_id: str, max_age: int = 86400):
    fields = dict(parse_qsl(init_data, keep_blank_values=True))
    received = fields.pop("hash", "")
    check = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
    secret_key = hmac.new(b"DiilMiniApp", client_secret.encode(), hashlib.sha256).digest()
    expected = hmac.new(secret_key, check.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected) or fields.get("app_id") != str(app_id):
        return None
    if time.time() - int(fields["auth_date"]) > max_age:
        return None
    return json.loads(fields["user"])

What is inside #

user contains id plus the fields of the scopes the user granted (firstName, lastName, languageCode, username, avatarUrl). Phone and email are never in launch data.