Telegram Mini App with Vue and FastAPI: Validating initData
Connect a Vue 3 Telegram Mini App to FastAPI, send raw initData, validate its HMAC signature, check auth_date, and avoid Invalid signature errors.
A Mini App can identify a Telegram user without a separate login form, but browser data is not automatically trustworthy. initDataUnsafe can be modified by the client. Telegram recommends sending the raw Telegram.WebApp.initData string to your backend and validating its signature there. The exact algorithm is documented in the official Mini Apps guide.
This article builds that flow with Vue 3 and FastAPI and explains the subtle double-decoding bug behind many Invalid signature errors.
Architecture
Telegram → Vue Mini App → X-Init-Data → FastAPI → HMAC validation → user
Vue only forwards Telegram's raw string. FastAPI validates the signature and age, then extracts the user. Profiles, orders, course access, and payments use that verified Telegram ID.
Important: initData depends on how the Mini App was launched. For authentication, use a launch method that supplies user data, such as a bot inline button (InlineKeyboardButton.web_app), the menu button, or the Main Mini App. A reply-keyboard KeyboardButton.web_app is intended for input interfaces and may not provide initData; it has a separate sendData() flow. Check the actual value of Telegram.WebApp.initData in each supported Telegram client before connecting it to your API. Launch methods and available data.
Load the Telegram SDK
Place the official SDK before your application bundle:
<script src="https://telegram.org/js/telegram-web-app.js"></script>
Then initialize the Mini App:
const tg = window.Telegram?.WebApp
tg?.ready()
tg?.expand()
Call ready() after applying the Telegram theme and preparing the first screen. This keeps the user from seeing an empty initial state.
Send raw initData from Vue
Centralize the header in your API client:
const API_URL = 'https://api.example.com/api/v1'
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const initData = window.Telegram?.WebApp?.initData ?? ''
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Init-Data': initData,
...options.headers,
},
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
Do not send a separate authoritative user_id, username, or payment flag. The backend should use only the identity extracted from validated initData.
Validate the signature in Python
initData is a query string. Remove hash, sort the remaining pairs, and join them with line feeds.
import hashlib
import hmac
import json
import re
import time
from urllib.parse import parse_qsl
class TelegramAuthError(ValueError):
pass
def validate_init_data(init_data: str, bot_token: str, max_age: int = 3600) -> dict:
if not init_data or len(init_data) > 16_384:
raise TelegramAuthError("Missing or oversized initData")
try:
pairs = parse_qsl(init_data, keep_blank_values=True, strict_parsing=True)
except ValueError as exc:
raise TelegramAuthError("Malformed initData") from exc
if len({key for key, _ in pairs}) != len(pairs):
raise TelegramAuthError("Duplicate initData field")
values = dict(pairs)
received_hash = values.pop("hash", None)
if not received_hash or not re.fullmatch(r"[0-9a-f]{64}", received_hash):
raise TelegramAuthError("Missing hash")
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(values.items())
)
secret_key = hmac.new(
b"WebAppData",
bot_token.encode(),
hashlib.sha256,
).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
raise TelegramAuthError("Invalid signature")
try:
auth_date = int(values["auth_date"])
user = json.loads(values["user"])
except (KeyError, TypeError, ValueError) as exc:
raise TelegramAuthError("Malformed auth_date or user") from exc
now = int(time.time())
if auth_date <= 0 or auth_date < now - max_age or auth_date > now + 30:
raise TelegramAuthError("initData expired")
if not isinstance(user, dict) or type(user.get("id")) is not int:
raise TelegramAuthError("Missing Telegram user ID")
return user
hmac.compare_digest provides a safe comparison. The auth_date check limits replay of an old, otherwise valid string.
Why valid users get Invalid signature
The most common cause is modifying the string before validation:
# Do not do this
init_data = unquote(init_data)
parse_qsl already handles percent-encoding. An extra unquote() can decode characters inside the JSON twice and change the data-check string. The user is genuine, but the signature can no longer match.
Other common causes include:
- validating with a different bot token;
- removing another field from the data-check string;
- sorting after values have been transformed;
- using
initDataUnsafeinstead of the raw string; - changing spaces or
+characters; - rejecting a reasonable
auth_datebecause the allowed age is too short.
Use a FastAPI dependency
from fastapi import Header, HTTPException
async def telegram_user(x_init_data: str = Header(alias="X-Init-Data")) -> dict:
try:
return validate_init_data(x_init_data, settings.bot_token)
except TelegramAuthError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
Protect an endpoint with the dependency:
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/profile")
async def profile(user: dict = Depends(telegram_user)):
return await profiles.get_or_create(telegram_id=user["id"])
The same dependency can protect profiles, courses, orders, and checkout endpoints.
Production checklist
- Launch the Mini App using a method that supplies user
initData(such as anInlineKeyboardButton.web_app, the menu button, or Main Mini App). - Confirm in real Telegram clients that
Telegram.WebApp.initDatais non-empty;KeyboardButton.web_appmay use the separatesendData()flow. - Forward raw
initDatawithout manual decoding. - Validate with the token of the same bot.
- Enforce a reasonable
auth_dateage. - Never accept the authoritative Telegram ID in a JSON body.
- Serve the frontend and API over HTTPS.
- Provide a clear retry screen when authorization fails.
Use an explicit preview mode for local development instead of weakening the production check.
For a product overview, read what a Telegram Mini App is. The complete Telegram bot course covers Mini Apps, payments, testing, and deployment as one system.
Need a booking flow, store, or customer account inside Telegram? Describe the project.
related
next step
Build a production Telegram bot end to end
The full course covers PostgreSQL, payments, Mini Apps, testing and Docker. Start with the free first lesson.
share
Need a website or web app?
I build web services, dashboards and APIs with a modern stack.