Building a Telegram Mini App with Blazor Server
A deep dive into creating a Telegram Mini App for BlueRobin that enables passkey authentication, leveraging Blazor Server, WebAuthn, and Telegram's native theming system.
Introduction
Following our Telegram Bot integration, we wanted to go further: allow users to register passkeys directly from Telegram. This eliminates the friction of opening a separate browser and provides a native-feeling experience within the Telegram app itself.
Telegram Mini Apps (formerly Web Apps) let you embed web content inside Telegram’s chat interface. Combined with Blazor Server and WebAuthn, we built a seamless passkey registration flow that:
- Authenticates via Telegram using
initDatavalidation - Registers device passkeys (Face ID, Touch ID, fingerprint)
- Adapts to Telegram’s theme (light/dark mode)
- Provides haptic feedback for native feel
Architecture Overview
sequenceDiagram
participant U as User
participant T as Telegram App
participant B as BlueRobin Bot
participant M as Mini App (Blazor)
participant API as BlueRobin API
participant DB as PostgreSQL
U->>T: Opens bot menu
T->>B: Menu button tap
B->>T: WebApp URL
T->>M: Opens Mini App
Note over M: Telegram SDK loads
M->>M: Get initData
M->>API: POST /auth/telegram/miniapp
API->>API: Validate HMAC signature
API->>DB: Lookup user by Telegram ID
DB-->>API: User record
API-->>M: Session cookie + user info
M->>U: Show "Create Passkey" UI
U->>M: Tap Create
M->>API: POST /webauthn/register/begin
API-->>M: Challenge + options
M->>T: navigator.credentials.create()
T->>U: Biometric prompt
U->>T: Face ID / Touch ID
T-->>M: Credential response
M->>API: POST /webauthn/register/complete
API->>DB: Store passkey
API-->>M: Success
M->>U: "Passkey Created!" + haptic
The Mini App Layout
Telegram Mini Apps need a specialized layout that:
- Loads the Telegram WebApp SDK
- Applies Telegram’s dynamic theme colors
- Exposes JS interop functions for Blazor
- Disables normal navigation (no header/sidebar)
@inherits LayoutComponentBase
@*
Telegram Mini App Layout
A minimal layout designed for Telegram WebApp context.
Features:
- No sidebar or header navigation
- Telegram theme integration via CSS variables
- Full viewport height
- Dark mode support matching Telegram's scheme
*@
<!DOCTYPE html>
<html lang="en" class="telegram-miniapp">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<base href="/" />
<HeadOutlet />
</head>
<body class="miniapp-body">
<div id="miniapp-root" class="miniapp-container">
@Body
</div>
<script src="_framework/blazor.web.js"></script>
<script src="js/webauthn.js"></script>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script>
// Initialize Telegram WebApp
window.Telegram.WebApp.ready();
window.Telegram.WebApp.expand();
// Apply Telegram theme colors as CSS variables
const tg = window.Telegram.WebApp;
const root = document.documentElement;
if (tg.themeParams) {
root.style.setProperty('--tg-bg-color', tg.themeParams.bg_color || '#ffffff');
root.style.setProperty('--tg-text-color', tg.themeParams.text_color || '#000000');
root.style.setProperty('--tg-hint-color', tg.themeParams.hint_color || '#999999');
root.style.setProperty('--tg-link-color', tg.themeParams.link_color || '#2481cc');
root.style.setProperty('--tg-button-color', tg.themeParams.button_color || '#2481cc');
root.style.setProperty('--tg-button-text-color', tg.themeParams.button_text_color || '#ffffff');
root.style.setProperty('--tg-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
}
// Set dark mode class if Telegram is in dark mode
if (tg.colorScheme === 'dark') {
document.documentElement.classList.add('dark');
}
// Expose interop functions for Blazor
window.getTelegramInitData = () => window.Telegram.WebApp.initData;
window.getTelegramUser = () => window.Telegram.WebApp.initDataUnsafe?.user;
window.closeTelegramWebApp = () => window.Telegram.WebApp.close();
window.showTelegramAlert = (msg) => window.Telegram.WebApp.showAlert(msg);
window.hapticFeedback = function(type) {
if (window.Telegram.WebApp.HapticFeedback) {
switch(type) {
case 'success':
window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
break;
case 'error':
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
break;
case 'impact':
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
break;
}
}
};
</script>
</body>
</html> Telegram Theme Integration
One of Telegram’s best features is automatic theme adaptation. Users expect apps to match their Telegram color scheme. We achieve this with CSS custom properties:
/* Telegram Mini App Theme Variables */
.telegram-miniapp {
--tg-bg-color: #ffffff;
--tg-text-color: #000000;
--tg-hint-color: #999999;
--tg-link-color: #2481cc;
--tg-button-color: #2481cc;
--tg-button-text-color: #ffffff;
--tg-secondary-bg-color: #f0f0f0;
}
.telegram-miniapp.dark {
--tg-bg-color: #18222d;
--tg-text-color: #ffffff;
--tg-hint-color: #708499;
--tg-link-color: #6ab2f2;
--tg-button-color: #2481cc;
--tg-button-text-color: #ffffff;
--tg-secondary-bg-color: #232e3c;
}
.miniapp-body {
margin: 0;
padding: 0;
min-height: 100vh;
min-height: 100dvh; /* Dynamic viewport height for mobile */
background-color: var(--tg-bg-color);
color: var(--tg-text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Telegram-style button */
.tg-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.875rem 1.5rem;
background-color: var(--tg-button-color);
color: var(--tg-button-text-color);
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.1s ease;
-webkit-tap-highlight-color: transparent;
}
.tg-button:active {
transform: scale(0.98);
opacity: 0.9;
}
.tg-button:disabled {
opacity: 0.5;
cursor: not-allowed;
} The JavaScript in the layout reads themeParams from Telegram’s SDK and applies them as CSS variables. This means our styles automatically adapt when users switch between light and dark mode.
Authenticating via initData
Telegram Mini Apps receive initData - a URL-encoded string containing user information signed by Telegram. We must validate this signature server-side before trusting the user identity.
/// <summary>
/// Validates Telegram Mini App initData according to Telegram's specification.
/// </summary>
private TelegramValidationResult ValidateTelegramInitData(string initData, string botToken)
{
try
{
// Parse the initData as URL-encoded params
var pairs = initData.Split('&')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(
p => Uri.UnescapeDataString(p[0]),
p => Uri.UnescapeDataString(p[1]));
if (!pairs.TryGetValue("hash", out var receivedHash))
return new TelegramValidationResult { IsValid = false, Error = "Missing hash" };
// Check auth_date freshness (5 minute window)
if (!pairs.TryGetValue("auth_date", out var authDateStr) ||
!long.TryParse(authDateStr, out var authDate))
return new TelegramValidationResult { IsValid = false, Error = "Invalid auth_date" };
var authTime = DateTimeOffset.FromUnixTimeSeconds(authDate);
if (DateTimeOffset.UtcNow - authTime > TimeSpan.FromMinutes(5))
return new TelegramValidationResult { IsValid = false, Error = "Data expired" };
// Build data-check-string (sorted alphabetically, excluding hash)
var dataCheckString = string.Join("\n",
pairs
.Where(p => p.Key != "hash")
.OrderBy(p => p.Key)
.Select(p => $"{p.Key}={p.Value}"));
// Calculate secret key: HMAC-SHA256(botToken, "WebAppData")
using var hmacForSecret = new HMACSHA256(Encoding.UTF8.GetBytes("WebAppData"));
var secretKey = hmacForSecret.ComputeHash(Encoding.UTF8.GetBytes(botToken));
// Calculate hash: HMAC-SHA256(dataCheckString, secretKey)
using var hmacForHash = new HMACSHA256(secretKey);
var calculatedHash = Convert.ToHexString(
hmacForHash.ComputeHash(Encoding.UTF8.GetBytes(dataCheckString))).ToLower();
if (calculatedHash != receivedHash)
return new TelegramValidationResult { IsValid = false, Error = "Invalid signature" };
// Extract user ID from the user JSON
if (pairs.TryGetValue("user", out var userJson))
{
var userData = JsonSerializer.Deserialize<TelegramUser>(userJson);
return new TelegramValidationResult
{
IsValid = true,
TelegramUserId = userData?.Id ?? 0
};
}
return new TelegramValidationResult { IsValid = false, Error = "Missing user data" };
}
catch (Exception ex)
{
return new TelegramValidationResult { IsValid = false, Error = ex.Message };
}
} The Passkey Setup Page
With authentication handled, we can build the passkey registration UI. This page uses the special layout and communicates with our WebAuthn endpoints:
@page "/telegram/setup-passkey"
@layout TelegramMiniappLayout
@inject IJSRuntime JS
@inject HttpClient Http
<PageTitle>Setup Passkey - BlueRobin</PageTitle>
<div class="miniapp-container">
@switch (_state)
{
case PageState.Loading:
<div class="miniapp-loading">
<div class="miniapp-spinner"></div>
<p class="tg-text-hint">Connecting to BlueRobin...</p>
</div>
break;
case PageState.Ready:
<div class="passkey-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
</div>
<h1 class="passkey-title">Setup Passkey</h1>
<p class="passkey-description">
Create a passkey to sign in to BlueRobin securely without a password.
</p>
<ul class="passkey-features">
<li><span class="icon">🔐</span><span>Uses your device's biometrics</span></li>
<li><span class="icon">⚡</span><span>Faster than typing passwords</span></li>
<li><span class="icon">🛡️</span><span>Phishing-resistant authentication</span></li>
<li><span class="icon">📱</span><span>Works across your devices</span></li>
</ul>
<button class="tg-button" @onclick="CreatePasskey" disabled="@_isCreating">
@(_isCreating ? "Creating Passkey..." : "Create Passkey")
</button>
break;
case PageState.Success:
<div class="miniapp-status">
<div class="miniapp-status-icon success">✓</div>
<h2 class="miniapp-status-title">Passkey Created!</h2>
<p class="miniapp-status-message">
You can now use this passkey to sign in to BlueRobin on any device.
</p>
<button class="tg-button" @onclick="CloseApp">Done</button>
</div>
break;
}
</div>
@code {
private enum PageState { Loading, NotLinked, Ready, Success, Error }
private PageState _state = PageState.Loading;
private bool _isCreating;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await AuthenticateWithTelegram();
}
}
private async Task AuthenticateWithTelegram()
{
var initData = await JS.InvokeAsync<string>("getTelegramInitData");
if (string.IsNullOrEmpty(initData))
{
_state = PageState.Error;
StateHasChanged();
return;
}
// Authenticate with our API
var response = await Http.PostAsJsonAsync("/auth/telegram/miniapp", new { InitData = initData });
if (response.IsSuccessStatusCode)
{
_state = PageState.Ready;
}
else
{
_state = PageState.NotLinked;
}
StateHasChanged();
}
private async Task CreatePasskey()
{
_isCreating = true;
StateHasChanged();
try
{
// Call WebAuthn registration flow
var success = await JS.InvokeAsync<bool>("webauthnRegister");
if (success)
{
await JS.InvokeVoidAsync("hapticFeedback", "success");
_state = PageState.Success;
}
}
finally
{
_isCreating = false;
StateHasChanged();
}
}
private async Task CloseApp()
{
await JS.InvokeVoidAsync("closeTelegramWebApp");
}
} Configuring the Bot Menu
The Telegram bot configures its menu button to open our Mini App:
private async Task SetupBotMenuAsync(CancellationToken cancellationToken)
{
var menuButton = new MenuButtonWebApp
{
Text = "🔐 Setup Passkey",
WebApp = new WebAppInfo { Url = _options.MiniAppUrl }
};
await _botClient.SetChatMenuButton(menuButton: menuButton, cancellationToken: cancellationToken);
}
Users can also access it via the /passkey command or inline buttons after linking their account.
Deployment Considerations
The Mini App URL must be HTTPS and publicly accessible. In our staging environment, we use https://web-staging.bluerobin.local/telegram/setup-passkey.
User Experience Flow
- User opens BlueRobin bot in Telegram
- Taps the ”🔐 Setup Passkey” menu button
- Mini App opens within Telegram
- Automatic authentication via
initData - User sees passkey benefits and taps “Create Passkey”
- Device prompts for biometric (Face ID/Touch ID)
- Success! Haptic feedback confirms registration
- User closes Mini App and returns to chat
The entire flow happens without leaving Telegram, taking only seconds.
Conclusion
Telegram Mini Apps provide a powerful way to extend your bot’s capabilities with rich web interfaces. Combined with Blazor Server’s component model and WebAuthn’s passwordless authentication, we created a seamless passkey registration experience.
Key Takeaways:
- Use Telegram’s
initDatafor secure, automatic authentication - Apply theme variables for native look and feel
- Provide haptic feedback for mobile interactions
- Scope session cookies carefully for cross-origin scenarios
- Keep the Mini App focused—it’s a modal, not a full app
Next in Series: NATS-Powered Notification System - How we built the real-time notification pipeline from document processing to Telegram.