Agent Reliability LabRequest a scope
Web

White screen in a Telegram Mini App: six engineering faults and how to fix each

Works in a desktop browser, blank on iPhone: six faults behind a white Telegram Mini App, from frame headers to the SSL chain, with fixes.

A team builds a Telegram Mini App using modern frameworks, tests it in a desktop browser emulator, and sees sub-50ms API responses. They deploy to a test environment and connect it to BotFather, only to find that up to 40% of iPhone users see a blank white screen. The app crashes silently on iOS, desktop clients throw users into external browsers, and backgrounding the app leads to infinite loading states or authentication errors.

The WebView is not the browser you tested in

A Telegram Mini App is a hybrid web application running inside an isolated system WebView, controlled by the Telegram WebApp SDK bridge. The execution environment differs across platforms. On iOS (iPhone and iPad), the app renders through the Apple WebKit engine (WKWebView) with strict privacy policies (Intelligent Tracking Prevention), specific sandbox behavior for cookies, and virtual keyboard management. On Android, it uses the Android System WebView component based on Chromium, carrying its own caching and permission constraints. On Telegram Desktop and Telegram Web, the application loads inside an isolated HTML <iframe> element subject to cross-origin embedding security rules.

Summary matrix of failure points

The table below lists the primary symptoms, vulnerable platforms, and solutions.

Fault Failing platform Visible symptom Root cause Fix
CSP and X-Frame-Options headers Telegram Desktop, Web (K / A) White rectangle, console errors X-Frame-Options: SAMEORIGIN or DENY frame-ancestors https://*.telegram.org directive
WebKit localStorage crash iOS (iPhone, iPad) White screen before first paint SecurityError: The operation is insecure exception Safe-storage polyfill with in-memory fallback and CloudStorage
initData validation failures All platforms 401 Unauthorized error, infinite loader Invalid HMAC-SHA256, sorting failure, expired date Reference signature validation on the backend with time allowance
Viewport shift by keyboard iOS (iPhone) Content shifts up, button off screen WebKit resize behavior when keyboard hides Call expand(), handle viewportChanged, --tg-viewport-height
Invalid BotFather link Desktop, Mobile Opens external browser instead of WebApp Standard URL instead of WebAppInfo object Configure Web App button in BotFather and inline keyboard
Incomplete SSL chain (CA) iOS (Safari WebKit) White screen without network requests Missing intermediate certificate in Nginx Install full certificate bundle fullchain.pem

Fault 1. CSP and X-Frame-Options blocking iframes

In Telegram Desktop for Windows and macOS, and in browser clients (web.telegram.org), a Mini App opens inside a standard HTML <iframe>. Default configurations for modern web servers (Nginx, Caddy, Apache), cloud proxies (Cloudflare), and frameworks (Next.js, Remix, Nuxt) send headers that prevent clickjacking attacks:

  • X-Frame-Options: DENY
  • X-Frame-Options: SAMEORIGIN
  • Content-Security-Policy: frame-ancestors 'none'
  • Content-Security-Policy: frame-ancestors 'self'

When a browser encounters this header in a page loading inside an <iframe>, it blocks the document render. The desktop browser console shows a Refused to display in a frame because it set 'X-Frame-Options' to 'sameorigin' error, and the user sees a white rectangle. On a mobile phone using a native WebView without a parent frame, the application might open normally.

The fix

The X-Frame-Options header is an obsolete standard that does not support multiple allowed domains. The current W3C standard requires the frame-ancestors directive in the Content-Security-Policy header.

For an Nginx web server, configure the Mini App virtual host as follows:

server {
    server_name app.example.com;

    # Disable obsolete X-Frame-Options header if set by the backend
    proxy_hide_header X-Frame-Options;

    # Allow iframe embedding only for this site and Telegram clients
    add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://*.telegram.org https://telegram.org;" always;

    # Base headers against content type spoofing
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

If the application runs on Next.js without a proxying web server, adjust the headers in next.config.js:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "frame-ancestors 'self' https://web.telegram.org https://*.telegram.org https://telegram.org;",
          },
        ],
      },
    ];
  },
};

Fault 2. Apple WebKit sandbox and localStorage crashes on iOS

This causes the majority of mobile traffic incidents on iOS. The WebKit engine (WKWebView) operates with strict personal data isolation. If the user enables block all cookies, activates private browsing, or triggers Intelligent Tracking Prevention (ITP), WebKit blocks access to synchronous web storage:

  • window.localStorage
  • window.sessionStorage

Any attempt to read or write a value throws a browser-level exception: DOMException: The operation is insecure. or SecurityError: The operation is insecure.

const token = localStorage.getItem('auth_token');

Most single-page application (SPA) state managers (Zustand with the persist middleware, Redux-Persist, Pinia Persistedstate) access localStorage synchronously during JavaScript module import and initialization. This occurs before a React <ErrorBoundary> component runs and before the framework mounts the first DOM node. The unhandled exception halts the JavaScript bundle execution, preventing the renderer from starting and leaving the user with a white screen.

The fix

Step 1. SafeStorage wrapper. Do not access window.localStorage directly. Create a storage module that catches iOS sandbox errors and falls back to memory (in-memory Map):

// safeStorage.ts
class SafeStorage implements Storage {
  private memory = new Map<string, string>();
  private isAvailable: boolean;

  constructor() {
    this.isAvailable = this.checkStorage();
  }

  private checkStorage(): boolean {
    try {
      if (typeof window === 'undefined' || !window.localStorage) return false;
      const testKey = '__tma_storage_test__';
      window.localStorage.setItem(testKey, '1');
      window.localStorage.removeItem(testKey);
      return true;
    } catch {
      return false;
    }
  }

  get length(): number {
    return this.isAvailable ? window.localStorage.length : this.memory.size;
  }

  getItem(key: string): string | null {
    if (this.isAvailable) {
      try {
        return window.localStorage.getItem(key);
      } catch {
        return this.memory.get(key) ?? null;
      }
    }
    return this.memory.get(key) ?? null;
  }

  setItem(key: string, value: string): void {
    if (this.isAvailable) {
      try {
        window.localStorage.setItem(key, value);
        return;
      } catch {
        // Fallback to memory
      }
    }
    this.memory.set(key, String(value));
  }

  removeItem(key: string): void {
    if (this.isAvailable) {
      try {
        window.localStorage.removeItem(key);
      } catch {}
    }
    this.memory.delete(key);
  }

  clear(): void {
    if (this.isAvailable) {
      try {
        window.localStorage.clear();
      } catch {}
    }
    this.memory.clear();
  }

  key(index: number): string | null {
    if (this.isAvailable) {
      try {
        return window.localStorage.key(index);
      } catch {}
    }
    return Array.from(this.memory.keys())[index] ?? null;
  }
}

export const safeStorage = new SafeStorage();

Step 2. Native Telegram CloudStorage. For persistent user settings and cart state, use the native API: Telegram.WebApp.CloudStorage. It stores data on Telegram servers, binds to the account ID, ignores Safari cookie settings, and synchronizes across devices.

Fault 3. initData validation failures

At launch, the Telegram client passes an initialization string: window.Telegram.WebApp.initData. It contains the user profile, an auth_date timestamp, and a cryptographic hash signature. The backend must verify this signature to prevent user spoofing. If validation fails, the backend returns a 401 Unauthorized status. Without an explicit error screen on the frontend, the app hangs.

HMAC-SHA256 validation rules

  1. Secret key calculation. The secret key is computed as an HMAC-SHA256 hash of the bot token, using the string "WebAppData" as the hashing key: secret_key = HMAC_SHA256(key="WebAppData", msg=BOT_TOKEN).
  2. Alphabetical sorting. Parameters must be sorted lexicographically by key before concatenation into data_check_string with newline characters \n. The hash parameter is excluded.
  3. Session expiration. Telegram generates initData once per opening. If a user backgrounds the app for hours, auth_date ages. A tight backend timeout rejects subsequent API requests.
  4. Encoding rules. The user field passes as an escaped JSON string. Automatic double URL-decoding by an HTTP framework modifies the check string and breaks the hash.

Python validation algorithm

The following Python implementation verifies the signature and session age:

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

def validate_telegram_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict:
    if not init_data:
        raise ValueError("Empty initData string")

    # Parse query string parameters
    parsed_data = dict(parse_qsl(init_data, keep_blank_values=True))

    if "hash" not in parsed_data:
        raise ValueError("Missing hash parameter")

    received_hash = parsed_data.pop("hash")

    # Check session age
    auth_date = int(parsed_data.get("auth_date", 0))
    if time.time() - auth_date > max_age_seconds:
        raise ValueError("Session expired (auth_date expired)")

    # Build data_check_string with alphabetical sorting
    check_items = [f"{k}={v}" for k, v in sorted(parsed_data.items())]
    data_check_string = "\n".join(check_items)

    # Calculate secret key: HMAC-SHA256("WebAppData", bot_token)
    secret_key = hmac.new(b"WebAppData", bot_token.encode("utf-8"), hashlib.sha256).digest()

    # Calculate check hash from data_check_string
    calculated_hash = hmac.new(secret_key, data_check_string.encode("utf-8"), hashlib.sha256).hexdigest()

    # Constant-time hash comparison
    if not hmac.compare_digest(calculated_hash, received_hash):
        raise ValueError("Signature mismatch")

    # Parse JSON user field
    if "user" in parsed_data:
        parsed_data["user"] = json.loads(parsed_data["user"])

    return parsed_data

Fault 4. iOS viewport and virtual keyboard shifts

Applying height: 100vh inside the WebKit engine on an iPhone causes interface layout issues. When a user focuses an <input> field, iOS brings up the virtual keyboard and pushes the WebView container up. Upon hiding the keyboard, WebKit does not restore the viewport position, pushing lower navigation elements out of bounds. The 100vh height also includes the status bar and Home Bar. The application overflows, triggering an elastic rubber-banding scroll effect.

The fix

1. Call the expand method. After calling Telegram.WebApp.ready(), execute expand() to stretch the app to the maximum available screen height:

if (window.Telegram?.WebApp) {
  const tg = window.Telegram.WebApp;
  tg.ready();
  tg.expand();
}

2. Use Telegram CSS variables. The WebApp SDK provides computed height variables: --tg-viewport-height and --tg-viewport-stable-height.

/* Telegram Mini App wrapper */
.tma-wrapper {
  min-height: var(--tg-viewport-stable-height, 100vh);
  height: var(--tg-viewport-height, 100vh);
  width: 100%;
  overflow-x: hidden;
  overflow-y: auto;
  /* Prevent rubber-band scroll in WebKit */
  overscroll-behavior-y: none;
  /* Account for the iPhone Home Bar safe area */
  padding-bottom: env(safe-area-inset-bottom, 16px);
}

3. Set a strict meta viewport. Define a viewport in index.html without gesture scaling:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">

Fault 5. BotFather configuration and external browser redirects

A button that passes a standard URL forces Telegram to open an external system browser:

# Fails: opens external browser
InlineKeyboardButton(text="Open catalog", url="https://app.example.com")

The external browser lacks the window.Telegram.WebApp object. Calling Telegram.WebApp.ready() throws a TypeError.

The fix

Configure the button using a WebAppInfo object:

from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo

# Correct: opens Mini App inside Telegram
keyboard = InlineKeyboardMarkup(inline_keyboard=[
    [
        InlineKeyboardButton(
            text="Open shop",
            web_app=WebAppInfo(url="https://app.example.com")
        )
    ]
])

To configure a persistent menu button in BotFather:

  1. Send the /setmenubutton command.
  2. Select the bot.
  3. Submit the HTTPS link to the web application.

To debug on a device, include a mobile console script conditional on a URL parameter:

// Connect mobile debugger via ?debug=1 parameter
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug')) {
  const script = document.createElement('script');
  script.src = 'https://cdn.jsdelivr.net/npm/eruda';
  script.onload = () => {
    if (window.eruda) window.eruda.init();
  };
  document.head.appendChild(script);
}

Fault 6. Incomplete SSL chain (Intermediate CA)

If the server omits the intermediate certificate, desktop browsers recover via Authority Information Access (AIA). Apple WebKit on iOS blocks background certificate retrieval. If the Nginx configuration references only the leaf certificate (cert.pem), iOS terminates the TLS handshake before sending an HTTP request, resulting in a white screen and no access logs.

The fix

Test the domain with openssl:

openssl s_client -connect app.example.com:443 -servername app.example.com

A Verify return code: 21 output indicates a broken chain. Update Nginx to use the full chain bundle:

# Correct: full trust chain for all devices
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

Before sending traffic

  • Cross-environment testing: Verified in iOS (WebKit), Android (Chromium), Telegram Desktop, and Telegram Web.
  • Frame headers: Set Content-Security-Policy with frame-ancestors https://*.telegram.org and removed X-Frame-Options.
  • Safe storage handling: Wrapped localStorage in a try/catch block and migrated long-term data to CloudStorage.
  • Server-side validation: Verified initData using HMAC-SHA256, sorted parameters alphabetically, and set a reasonable session timeout.
  • Viewport adaptation: Called Telegram.WebApp.expand(), applied var(--tg-viewport-height, 100vh), and set overscroll-behavior-y: none.
  • SSL chain verification: Installed the fullchain.pem bundle and verified the chain integrity.
  • Mobile debugger: Added an on-device console trigger via URL parameters.
What to check in your system
  • Does your web server send an X-Frame-Options header that blocks iframes?
  • Do you wrap localStorage access in a try/catch block to handle iOS WebKit sandbox restrictions?
  • Does your backend validate the Telegram initData signature with a time limit?
  • Is your SSL certificate chain complete, including intermediate certificates?
Agent Reliability Lab
● Online