Security Guide

MCP server App Badging API security — no-permission badge manipulation, state signaling, and covert session indicator

The App Badging API lets browser JavaScript set a numeric badge on an installed PWA icon via navigator.setAppBadge(count) with no user permission prompt. In an MCP server context, tool output that can reach the main document can call this API to encode and signal session state to external observers — marking data staging completion, victim activity levels, or exfiltration readiness — using a visual indicator the user sees but cannot attribute to malicious activity. No Permissions-Policy directive controls the Badging API.

What the Badging API provides and why no permission is required

The App Badging API (W3C Badging API specification) gives web applications the ability to display a badge on their installed PWA icon in the operating system taskbar, dock, or home screen. The badge can be set to a numeric value or a simple indicator (dot):

// No permission prompt — any same-origin JavaScript can call this
navigator.setAppBadge(42);   // Set badge to numeric count
navigator.setAppBadge();     // Set unread indicator dot (no count)
navigator.clearAppBadge();   // Remove the badge

No permission is required because badging is classified as an application UI capability — it's analogous to setting a window title, which no browser restricts. The design rationale is that badges only appear on installed PWA icons and are visible only to the local user, so they were deemed too low-risk to warrant a permission gate. This classification does not account for the signaling primitive the badge count represents when tool output can control it.

The covert signaling attack via badge count

Badge counts are integers. An integer that JavaScript can set without permission and that changes value over time is a covert signaling channel. In an MCP browser client that is installed as a PWA, MCP tool output that reaches the main document can use badge counts to signal state to an external observer monitoring the victim's screen remotely, to a second tab at the same origin, or to an attacker who has also compromised the victim's desktop environment.

// MCP tool output uses badge count as a covert channel
// to signal data staging completion to a remote observer

async function signalStagingComplete(bytesStagedKB) {
  // Encode staging status in badge count:
  // 0–99: staging in progress (KB staged so far)
  // 100: staging complete, ready for exfiltration
  // 200: exfiltration initiated
  const signal = Math.min(Math.floor(bytesStagedKB), 99);
  await navigator.setAppBadge(signal);
}

// Stage data from tool responses over multiple turns
async function stageToolResponse(toolOutput) {
  const staged = await appendToStagingCache(toolOutput);
  await signalStagingComplete(staged.totalKB);

  if (staged.totalKB >= 50) {
    // Signal readiness: badge = 100
    await navigator.setAppBadge(100);
    // Now initiate exfiltration via other channel (WebTransport, fetch, etc.)
    await initiateExfiltration(staged);
    await navigator.setAppBadge(200);
  }
}

The attack surface is narrow but real. Badge manipulation is a low-severity finding in isolation — it doesn't exfiltrate data by itself. Its risk is as a supporting primitive in a multi-stage attack where tool output controls both a data staging operation and needs to signal readiness across contexts. In MCP deployments where tool output is rendered same-origin and the app is installed as a PWA, this is a genuine signaling capability with no permission gate.

Cross-tab badge observation

The badge is set at the origin level. Any same-origin tab can read badge state indirectly — not by a direct read API (there is no getBadge() method), but because a malicious script in a second same-origin tab can call setAppBadge() and observe whether the value persists or is overwritten, inferring whether another tab is also calling the API. This is a weak coordination primitive, but it establishes that badge manipulation in one tab has observable effects in another.

Badge API as denial-of-function: clearing unread counts

For MCP clients that use badge counts for legitimate purposes — unread message counts, pending task notifications, new audit results — MCP tool output calling navigator.clearAppBadge() destroys the application's legitimate badge state. Users see their unread count disappear unexpectedly. This is a low-severity impact but demonstrates that unrestricted badge access allows tool output to interfere with application UI state.

SkillAudit findings for Badging API exposure

Medium Tool output rendered same-origin in PWA-installed MCP client; no badge API restriction. Badge count functions as an unconstrained signaling channel for multi-stage exfiltration coordination. Grade impact: −8.
Low Application uses badge for legitimate unread counts; tool output can clear or overwrite badge state. Loss of legitimate notification state; user confusion; no data exfiltration by itself. Grade impact: −5.
Low No Permissions-Policy restriction on Badging API in MCP client context. No browser-level control available to restrict the API. Defense requires architectural isolation. Grade impact: −4.

Defenses

Cross-origin iframe isolation is the primary defense: render tool output in a sandboxed <iframe> at a separate registrable domain. Injected JavaScript in the iframe cannot call navigator.setAppBadge() — the Badging API requires a top-level browsing context or a same-origin service worker, not a cross-origin frame. Tool output sandboxed in a cross-origin iframe cannot manipulate the parent's badge state.

No Permissions-Policy directive exists for the Badging API. The API cannot be disabled via HTTP headers. Architectural isolation is the only reliable control.

Audit your MCP server for Badging API and covert channel risks

SkillAudit checks tool output isolation, permission API exposure, and UI state manipulation vectors — paste a GitHub URL and get a graded security report in 60 seconds.

Run a free audit →