Security Guide

MCP server IndexedDB security — per-origin storage attack surface, persistent XSS via cached tool output, key range injection, cross-tab races

IndexedDB is the browser's primary structured storage mechanism, and MCP clients increasingly use it to cache tool output — so that repeated calls to slow or expensive tools return instantly from the local cache. The caching logic is usually written quickly and optimistically: write the tool result, read it back later, render it. The security problem is that "write the tool result" and "render it later" are two distinct moments separated by potentially a browser restart. If the write step does not sanitize and the render step uses innerHTML, every future page load re-executes a script that was injected into the cache once and never cleaned up.

IndexedDB is per-origin, not per-tab or per-session

IndexedDB databases are scoped to the origin — the combination of scheme, host, and port that defines the browser's same-origin boundary. Every tab, worker, shared worker, and service worker running on the same origin shares full read and write access to all IndexedDB databases on that origin. There is no tab-level isolation, no session isolation, and no user-account isolation. When a user logs out and another user logs in on the same device and origin, the second user's browser context can open and query any IndexedDB database the first user's session wrote to, unless those records were explicitly deleted at logout.

For MCP clients, this has three direct consequences. First, a stored XSS payload anywhere on the origin can open the MCP client's IndexedDB database, enumerate its object stores, and read all cached tool results. Second, that same XSS payload can write malicious content into the cache, which will be retrieved and rendered by the legitimate MCP client on the next page load — potentially after the XSS infection has been cleaned up, making the injected cache entry a persistence mechanism. Third, in shared-device scenarios (kiosk, shared workstation), session A's tool cache is readable by session B unless the MCP client clears IndexedDB on logout.

The key fact: IndexedDB is a persistent, origin-scoped store. A malicious write to IndexedDB survives browser restarts, tab closures, and session terminations. If the MCP client renders cached IndexedDB content as HTML without sanitization, a single successful injection creates a cross-session, self-perpetuating XSS that re-executes on every subsequent page load.

Persistent cross-session XSS via unsanitized cached tool output

The attack path has four steps. Step one: the attacker finds a way to inject a malicious string into a tool's response. This could be via a compromised MCP server, via prompt injection that influences a tool's return value, or via any server-side vulnerability that lets an attacker influence what a read_file or search tool returns. Step two: the MCP client receives the malicious tool response and writes it to IndexedDB without sanitization. Step three: on the next page load — which may be days later — the MCP client checks its IndexedDB cache, finds the stored response, and renders it into the DOM with innerHTML. Step four: the injected script executes in the page's full JavaScript context.

// VULNERABLE: write unsanitized tool output to IndexedDB, then render as innerHTML

// Writing the tool result (e.g., in a service worker after receiving MCP response):
async function cacheToolResult(db, toolName, params, result) {
  const tx = db.transaction('tool-cache', 'readwrite');
  const store = tx.objectStore('tool-cache');
  const key = `${toolName}:${JSON.stringify(params)}`;

  // VULNERABLE: result is stored as-is — no sanitization
  // If result is '<img src=x onerror="stealSession()">', it is stored verbatim
  await store.put({ key, result, cachedAt: Date.now() });
  await tx.done;
}

// Reading and rendering on next page load:
async function renderCachedResult(db, toolName, params) {
  const tx = db.transaction('tool-cache', 'readonly');
  const store = tx.objectStore('tool-cache');
  const key = `${toolName}:${JSON.stringify(params)}`;
  const cached = await store.get(key);

  if (cached) {
    // VULNERABLE: unsanitized HTML string read from IndexedDB assigned to innerHTML
    // The malicious onerror handler executes here, on every page load that hits the cache
    document.getElementById('tool-output').innerHTML = cached.result;
  }
}

// SECURE version — sanitize on write AND use safe rendering on read:
import DOMPurify from 'dompurify';

async function cacheToolResultSafe(db, toolName, params, result) {
  const tx = db.transaction('tool-cache', 'readwrite');
  const store = tx.objectStore('tool-cache');
  const key = `${toolName}:${JSON.stringify(params)}`;

  // Sanitize before storage — malicious content never reaches the database
  const sanitized = DOMPurify.sanitize(result, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: []   // No event handler attributes permitted
  });

  await store.put({ key, result: sanitized, cachedAt: Date.now() });
  await tx.done;
}

async function renderCachedResultSafe(db, toolName, params) {
  const tx = db.transaction('tool-cache', 'readonly');
  const store = tx.objectStore('tool-cache');
  const key = `${toolName}:${JSON.stringify(params)}`;
  const cached = await store.get(key);

  if (cached) {
    // For plain text tool output: always prefer textContent over innerHTML
    const output = document.getElementById('tool-output');
    output.textContent = cached.result;   // Treats content as text, not HTML — always safe
  }
}

The key insight is that sanitization must happen at the point of storage, not only at the point of rendering. If you sanitize only on render, a write-only code path (such as a service worker that caches tool responses independently of the UI) may store unsanitized content that a future render path then trusts because it was "already in the database." Defense in depth requires sanitization at both the write boundary and the render boundary.

IDBKeyRange injection via user-controlled query bounds

IndexedDB queries that accept user input to define key ranges are vulnerable to a class of attack analogous to SQL injection: an attacker who controls the bounds of an IDBKeyRange can retrieve records across ranges they should not have access to. The typical scenario is an MCP tool that implements search or history filtering — the user provides a start and end date, a name prefix, or a numeric ID range, and the application constructs an IDBKeyRange from those values to query a tool-results history store.

If the application does not validate that the user-supplied bounds are within the user's permitted access range, an attacker can supply bounds that span other users' records (in a shared-device scenario) or retrieve records from tool calls they did not initiate.

// VULNERABLE: IDBKeyRange constructed directly from unvalidated user input
async function getToolHistory(db, userId, startDate, endDate) {
  const tx = db.transaction('tool-history', 'readonly');
  const store = tx.objectStore('tool-history');
  const index = store.index('by-date');

  // If startDate and endDate come from URL params or user input with no validation,
  // an attacker can supply startDate='0001-01-01' and endDate='9999-12-31'
  // to retrieve the ENTIRE tool history store across all users
  const range = IDBKeyRange.bound(startDate, endDate);
  const results = await index.getAll(range);
  return results;
}

// SECURE: validate bounds against user's permitted access range
async function getToolHistorySafe(db, userId, startDate, endDate) {
  // Enforce minimum/maximum bounds based on authenticated session data
  const sessionStart = getSessionStartDate(userId);   // e.g., account creation date
  const now = new Date().toISOString();

  // Clamp the user-provided bounds to the permitted range
  const clampedStart = startDate > sessionStart ? startDate : sessionStart;
  const clampedEnd = endDate < now ? endDate : now;

  // Additional validation: ensure start is before end
  if (clampedStart > clampedEnd) {
    return [];
  }

  const tx = db.transaction('tool-history', 'readonly');
  const store = tx.objectStore('tool-history');
  const index = store.index('by-user-date');  // compound index on [userId, date]

  // Use a compound key range that constrains to the current user's records
  const range = IDBKeyRange.bound(
    [userId, clampedStart],
    [userId, clampedEnd]
  );

  const results = await index.getAll(range);
  return results;
}

Cross-tab race conditions on concurrent tool calls

IndexedDB transactions provide isolation within a single transaction, but when two tabs make the same tool call simultaneously and independently open write transactions, they race to write their results to the same key. IndexedDB's transaction model uses a last-writer-wins semantics: whichever transaction commits last overwrites any earlier value at that key. In most contexts this is a minor correctness issue — one result gets discarded. In security-critical contexts, the race has more serious implications.

Consider an audit logging scenario where the MCP client writes the result of a verify_signature tool call to IndexedDB. If two tabs make verification calls simultaneously and one tab receives a forged "valid" response (from a compromised tool) while the other receives the genuine "invalid" response, the forged "valid" result may overwrite the genuine "invalid" result depending on timing. The application then reads back "valid" and proceeds as though verification succeeded.

The defense is to use IndexedDB transactions correctly for write isolation. Reads that must be consistent with a subsequent write must occur in the same readwrite transaction. For concurrent multi-tab scenarios, use the IDBTransaction's oncomplete event to confirm commit before acting on the result, and design key schemas so that concurrent writes go to different keys (e.g., by appending a per-call UUID to the key) and a separate reconciliation step determines the authoritative result.

Encryption at rest for sensitive cached tool results

When tool output contains sensitive data — file contents, API responses, personal information — encrypting the cache entries before storage provides defense in depth against same-origin attacks. An XSS payload that reads the IndexedDB records finds only ciphertext, not plaintext tool output. The critical constraint is that the encryption key must not be stored in IndexedDB alongside the ciphertext — that would make the encryption trivially bypassable.

// Encrypting tool output before IndexedDB storage using WebCrypto AES-GCM
// The encryption key lives only in memory — never written to storage

let _sessionKey = null;

async function getOrCreateSessionKey() {
  if (_sessionKey) return _sessionKey;
  // Generate a fresh AES-GCM key per session — stored only in memory
  _sessionKey = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    false,      // non-extractable — prevents key export via JS
    ['encrypt', 'decrypt']
  );
  return _sessionKey;
}

async function encryptAndStore(db, key, plaintext) {
  const encKey = await getOrCreateSessionKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));  // 96-bit IV for AES-GCM
  const encoder = new TextEncoder();

  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    encKey,
    encoder.encode(plaintext)
  );

  const tx = db.transaction('tool-cache', 'readwrite');
  await tx.objectStore('tool-cache').put({
    key,
    // Store ciphertext as ArrayBuffer + iv — NOT the key
    ciphertext: new Uint8Array(ciphertext),
    iv,
    cachedAt: Date.now()
  });
  await tx.done;
}

async function decryptAndRead(db, key) {
  const encKey = await getOrCreateSessionKey();
  const tx = db.transaction('tool-cache', 'readonly');
  const record = await tx.objectStore('tool-cache').get(key);
  if (!record) return null;

  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: record.iv },
    encKey,
    record.ciphertext
  );

  return new TextDecoder().decode(plaintext);
}

// INSECURE: do not store the key in IndexedDB
// await store.put({ key: 'encryption-key', value: exportedKey });  // attacker reads key + ciphertext
// INSECURE: do not derive the key from a predictable value like user ID
// const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(userId), ...);

IndexedDB cleanup on logout

Tool output cached in IndexedDB persists across browser sessions by default. On a shared device — or even on a personal device accessed by a partner, family member, or attacker with physical access — cached tool results from a previous session are readable by any same-origin page. The correct behavior at logout is to delete the MCP client's IndexedDB databases, or at minimum to delete the sensitive object stores. Use indexedDB.deleteDatabase(name) at logout to ensure cached tool output does not outlive the authenticated session.

Attack vector Prerequisite Mitigation
Persistent XSS via cached tool result Single successful tool output injection DOMPurify on write + textContent on read
IDBKeyRange injection User input flows to key range bounds Validate and clamp bounds; use compound user-scoped keys
Cross-tab race on concurrent writes Multiple tabs making same tool call Per-call UUID keys; explicit transaction oncomplete
Same-origin XSS reads cache XSS anywhere on origin AES-GCM encryption; key in memory only
Cross-session data leak on shared device Physical access to device Delete IndexedDB on logout

SkillAudit findings for IndexedDB misuse

Critical Tool output stored in IndexedDB without sanitization then rendered as innerHTML. Raw MCP tool response strings written to IndexedDB are later retrieved and assigned to element.innerHTML, creating a persistent cross-session XSS. A single malicious tool response injects a payload that re-executes on every subsequent page load that reads from the cache. Grade impact: −24.
High IDBKeyRange constructed from user input without bounds validation. An IndexedDB query uses user-supplied start and end values as key range bounds with no clamping to the current user's permitted access range. An attacker can supply extreme bounds to retrieve all records in the object store regardless of which user's session created them. Grade impact: −20.
High Encryption key stored in IndexedDB alongside encrypted data. The AES key used to encrypt sensitive tool output is itself written to IndexedDB — as an exported raw key, a CryptoKey stored via the structured clone algorithm, or a derived value stored as a separate record. Any same-origin attacker that reads the encrypted ciphertext can also read the key, trivially recovering the plaintext. Grade impact: −18.
Medium No transaction locking on concurrent writes from multiple tabs. Two tabs making the same tool call simultaneously write to the same IndexedDB key in separate transactions. Last-write-wins semantics mean one result is silently discarded. In security-critical contexts (verification results, audit records), the wrong value may be persisted. Grade impact: −14.
Medium IndexedDB not cleared on logout. Sensitive tool output written to IndexedDB during an authenticated session remains accessible after logout. On shared devices, a subsequent user's same-origin context can read all cached tool results from the previous session. Grade impact: −10.

Audit your MCP server for these issues

SkillAudit checks for IndexedDB security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →