Prevent Blank Viewports and Spoofed Data in Vue 3 Telegram Mini Apps

Building a service booking interface inside Telegram using Vue 3 seems straightforward until you test it on an actual mobile device or expose your API endpoint to live traffic. A naive implementation faces three critical breakages: the iOS virtual keyboard pushes your form inputs off-screen, the interface flashes default white styles for dark-mode users, and malicious users can forge client API requests claiming to be any user ID they want.

Telegram Mini Apps operate inside an embedded webview with strict platform-specific behaviors. The window.Telegram.WebApp object exposes theme preferences, viewport metrics, and authentication tokens. However, treating client-provided user structures like initDataUnsafe as authentic without backend verification opens your application to catastrophic data impersonation flaws.

In this tutorial, we will build a Vue 3 booking form that gracefully handles Telegram theme integration, stabilizes the iOS viewport, binds natively to Telegram's MainButton, and securely authenticates user actions on a Node.js backend using HMAC-SHA256.

Handling Viewport Resizing and Reactive Dark Mode in Vue 3

When opening a Mini App on iOS or Android, the webview defaults to a collapsed height unless explicitly instructed to expand. When a user taps a text input field, the native soft keyboard rises, compressing the visible viewport and shifting form elements unpredictably. If your CSS relies on standard 100vh units, your layout will jump or hide form inputs behind the keyboard.

Furthermore, Telegram allows users to dynamically switch between light and dark modes or customize theme colors entirely. The Telegram WebApp SDK provides a themeParams object containing hexadecimal values such as bg_color, text_color, hint_color, and button_color. Hardcoding hex values in your Vue CSS ensures your app will look broken in dark mode.

To solve this, we create a dedicated Vue composable named useTelegram. This composable initializes the SDK, expands the viewport to full height, binds Telegram CSS variables to the document root element, and updates reactive state whenever the viewport or theme changes.

// src/composables/useTelegram.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useTelegram() {
const tg = window.Telegram?.WebApp;
const isReady = ref(false);
const user = ref(tg?.initDataUnsafe?.user || null);
const viewportHeight = ref(tg?.viewportStableHeight || window.innerHeight);

const updateCssVariables = () => {
if (!tg?.themeParams) return;
const root = document.documentElement;
const tp = tg.themeParams;

if (tp.bg_color) root.style.setProperty('--tg-bg-color', tp.bg_color);
if (tp.text_color) root.style.setProperty('--tg-text-color', tp.text_color);
if (tp.hint_color) root.style.setProperty('--tg-hint-color', tp.hint_color);
if (tp.button_color) root.style.setProperty('--tg-button-color', tp.button_color);
if (tp.button_text_color) root.style.setProperty('--tg-button-text-color', tp.button_text_color);
if (tp.secondary_bg_color) root.style.setProperty('--tg-secondary-bg-color', tp.secondary_bg_color);
};

const handleViewportChange = () => {
if (tg?.viewportStableHeight) {
viewportHeight.value = tg.viewportStableHeight;
document.documentElement.style.setProperty('--tg-viewport-height', `${tg.viewportStableHeight}px`);
}
};

const handleThemeChange = () => {
updateCssVariables();
};

onMounted(() => {
if (tg) {
tg.ready();
tg.expand();
updateCssVariables();
handleViewportChange();

tg.onEvent('viewportChanged', handleViewportChange);
tg.onEvent('themeChanged', handleThemeChange);
isReady.value = true;
}
});

onUnmounted(() => {
if (tg) {
tg.offEvent('viewportChanged', handleViewportChange);
tg.offEvent('themeChanged', handleThemeChange);
}
});

return {
tg,
isReady,
user,
initData: tg?.initData || '',
viewportHeight
};
}

Using CSS variables derived directly from themeParams ensures your app dynamically repoints its palette without forcing a webview reload.

Submitting Data via sendData and Handling Native WebApp Events

Telegram offers two primary ways to process data in a Mini App: sending raw text back to the bot conversation via Telegram.WebApp.sendData(), or issuing asynchronous HTTP fetch() requests directly to your backend API.

Telegram.WebApp.sendData() drops a string payload back into the active Telegram chat as a web_app_data bot event. However, sendData() operates under strict constraints: it functions ONLY when the Mini App was launched from a static Keyboard Button (not an inline keyboard button or direct t.me link), and calling it immediately terminates the Mini App window. If your app was opened via a direct menu or link, calling sendData() will fail silently or throw a runtime error.

For complex forms like a booking engine, using Telegram's native MainButton to trigger an HTTP request to your Node.js API gives you complete control over user feedback, loading states, and error handling without crashing the webview.

Here is our Vue 3 form component BookingForm.vue that binds reactive fields to Telegram's native bottom button.

<!-- src/components/BookingForm.vue -->
<template>
<div class="booking-container">
<h2>Select a Slot</h2>
<form @submit.prevent>
<div class="form-group">
<label for="service">Service</label>
<select id="service" v-model="form.service">
<option value="consultation">Consultation (30 min)</option>
<option value="audit">Code Audit (60 min)</option>
</select>
</div>

<div class="form-group">
<label for="date">Date</label>
<input id="date" type="date" v-model="form.date" />
</div>

<div v-if="statusMessage" class="status-banner">
{{ statusMessage }}
</div>
</form>
</div>
</template>

<script setup>
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { useTelegram } from '../composables/useTelegram';

const { tg, initData } = useTelegram();
const statusMessage = ref('');

const form = reactive({
service: 'consultation',
date: ''
});

const submitBooking = async () => {
if (!tg?.MainButton) return;

tg.MainButton.showProgress(false);
statusMessage.value = 'Submitting booking...';

try {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `twa ${initData}`
},
body: JSON.stringify(form)
});

const result = await response.json();

if (!response.ok) {
throw new Error(result.error || 'Failed to create booking');
}

statusMessage.value = 'Booking confirmed!';
tg.MainButton.setText('SUCCESS');
setTimeout(() => tg.close(), 1200);
} catch (err) {
statusMessage.value = err.message;
tg.MainButton.hideProgress();
tg.MainButton.enable();
}
};

watch(
() => form.date,
(newDate) => {
if (tg?.MainButton) {
if (newDate) {
tg.MainButton.setText('CONFIRM BOOKING');
tg.MainButton.enable();
tg.MainButton.show();
} else {
tg.MainButton.hide();
}
}
}
);

onMounted(() => {
if (tg?.MainButton) {
tg.MainButton.onClick(submitBooking);
}
});

onUnmounted(() => {
if (tg?.MainButton) {
tg.MainButton.offClick(submitBooking);
tg.MainButton.hide();
}
});
</script>

<style scoped>
.booking-container {
padding: 16px;
background-color: var(--tg-bg-color, #ffffff);
color: var(--tg-text-color, #000000);
min-height: var(--tg-viewport-height, 100vh);
box-sizing: border-box;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
color: var(--tg-hint-color, #888888);
margin-bottom: 6px;
}
input, select {
width: 100%;
padding: 12px;
border: 1px solid var(--tg-hint-color, #ccc);
border-radius: 8px;
background: var(--tg-secondary-bg-color, #f5f5f5);
color: var(--tg-text-color, #000000);
}
.status-banner {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
background-color: var(--tg-secondary-bg-color, #eee);
}
</style>

Notice how the Authorization header passes initData directly. We never pass raw user JSON strings from initDataUnsafe. The raw initData string contains the signature necessary for the server to authenticate the request.

Rejecting Spoofed Submissions with HMAC Validation

Developers commonly make the fatal error of taking the user object out of window.Telegram.WebApp.initDataUnsafe and sending user.id to their backend. Because initDataUnsafe is executed purely in client-side JavaScript, any malicious user can open Web Inspector or edit local state to change user.id = 12345 to impersonate an administrator or modify another user's records.

To securely prove that request parameters originated from Telegram, your server must receive the raw, URL-encoded initData query string and calculate an HMAC-SHA256 signature.

Here is how Telegram's validation algorithm works step-by-step:

1. Parse the incoming initData query string into key-value pairs. 2. Extract the hash key and save it separately. 3. Sort all remaining keys alphabetically. 4. Format the sorted key-value pairs into key=value strings separated by newlines (\n). This forms the data_check_string. 5. Generate a secret key by calculating the HMAC-SHA256 hash of your bot token, using the constant string WebAppData as the HMAC key. 6. Generate an HMAC-SHA256 signature of the data_check_string using the secret key derived in step 5. 7. Compare the hex output against the original hash using crypto.timingSafeEqual to prevent timing attacks. 8. Verify that auth_date is sufficiently recent to block replay attacks.

Here is the production Express middleware for Node.js:

// server/middleware/validateInitData.js\ import crypto from 'crypto';

const BOT_TOKEN = process.env.BOT_TOKEN;
const MAX_AUTH_AGE_SECONDS = 86400; // 24 hours

export function validateTelegramAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('twa ')) {
return res.status(401).json({ error: 'Missing or malformed authorization header' });
}

const initDataRaw = authHeader.substring(4);
const params = new URLSearchParams(initDataRaw);
const hash = params.get('hash');

if (!hash) {
return res.status(400).json({ error: 'Missing hash parameter in initData' });
}

params.delete('hash');

// Sort parameters alphabetically
const sortedParams = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b));

const dataCheckString = sortedParams
.map(([key, value]) => `${key}=${value}`)
.join('\n');

// Compute derived HMAC key from bot token
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(BOT_TOKEN)
.digest();

// Compute HMAC hex signature of dataCheckString
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');

// Prevent timing attacks using constant-time comparison
const hashBuffer = Buffer.from(hash, 'hex');
const calculatedBuffer = Buffer.from(calculatedHash, 'hex');

if (
hashBuffer.length !== calculatedBuffer.length ||
!crypto.timingSafeEqual(hashBuffer, calculatedBuffer)
) {
return res.status(403).json({ error: 'Invalid HMAC signature. Data compromised.' });
}

// Check auth_date for replay attack protection
const authDate = parseInt(params.get('auth_date') || '0', 10);
const now = Math.floor(Date.now() / 1000);

if (now - authDate > MAX_AUTH_AGE_SECONDS) {
return res.status(403).json({ error: 'Authentication payload has expired' });
}

// Attach validated user object to request context
try {
const userData = JSON.parse(params.get('user') || '{}');
req.telegramUser = userData;
next();
} catch (err) {
return res.status(400).json({ error: 'Invalid embedded user JSON structure' });
}
}

With this middleware applied to your POST routes, spoofed submissions are rejected before touching your database.

Production Requirements

When deploying Vue 3 Telegram Mini Apps, keep these essential technical rules in mind:

1. Call WebApp.ready() AFTER Mounting Event Handlers: Always bind click handlers like tg.MainButton.onClick() and prepare reactive state before firing tg.ready(). If you invoke ready() first, Telegram dismisses the native splash screen prematurely, leading to race conditions on slow mobile CPU architectures where inputs render unattached. 2. Strict Replay Attack Thresholds: Telegram signatures remain mathematically valid indefinitely. If an attacker captures a valid initData string from network logs, they can replay it months later. Enforce a maximum age on auth_date (e.g., 86,400 seconds or less) in server validation middleware to render intercepted tokens useless. 3. Decouple MainButton Reactivity: Clean up native button event listeners inside Vue's onUnmounted hook using offClick(). If users navigate between multiple Vue views within the Mini App without detaching listeners, previous views will execute ghost handlers on button clicks.

Building reliable interfaces inside Telegram requires balancing native webview quirks with strict cryptographic backend checks. By decoupling client UI state from trust and verifying initData on every write operation, you keep user data isolated and resilient. If you need production-ready Telegram integrations, custom workflows, or enterprise webviews built with accuracy, partner with BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.