Security Guide

MCP server Web Locks API security — lock starvation DoS, lock name enumeration, cross-tab contention oracle, AbortSignal timeout, per-session namespacing

The Web Locks API provides named mutual exclusion across browser tabs and workers at the same origin. For MCP client implementations that serialize concurrent tool calls, this is a legitimate and useful primitive. The security risks emerge from its scope: all tabs and all workers at the same origin share a single flat lock namespace. A tab opened by an attacker at the same origin can probe which locks are currently held, enumerate active users' sessions via navigator.locks.query(), and starve legitimate lock acquisitions indefinitely by holding locks open. In multi-tenant MCP deployments, a lock named by one tenant blocks identically-named locks for every other tenant on the same origin.

What the Web Locks API does and where MCP servers use it

navigator.locks.request(name, callback) acquires an exclusive lock identified by the string name. The browser queues the request if another holder has the same lock name, and invokes the callback when the lock is granted. The lock is held until the promise returned by the callback resolves or rejects. All other callers requesting the same name block in a FIFO queue for the duration.

Browser-based MCP client implementations use Web Locks in several legitimate patterns: serializing concurrent tool calls that would produce race conditions if run simultaneously (two calls writing to the same IndexedDB key-path, two calls mutating the same local file via the File System Access API), rate-limiting expensive tool operations to one-at-a-time per session, and coordinating between the MCP client running in the main thread and tool execution logic running in a shared worker or service worker.

The lock name is a plain string. The specification provides no namespacing: "tool:file-write", "cache:rebuild", and "session:auth" are global lock names visible to every tab and worker at https://app.example.com. There is no per-tab scope, no per-user scope, and no per-session scope unless you construct those scopes yourself in the lock name string. This design choice — correct for its intended use case of coordinating shared browser resources — becomes a vulnerability surface when lock names encode user-identifying information or when lock behavior affects security-sensitive operations.

The Web Locks API is origin-scoped, not tab-scoped. Every tab, iframe (same-origin), shared worker, service worker, and dedicated worker at the same origin participates in the same lock namespace. An attacker who can open any page at your origin — including via a subdomain takeover or a hosted user content path — can interact with your application's locks.

Lock starvation DoS via long-held locks

The Web Locks API has no built-in maximum hold time and no maximum queue depth. A caller that acquires a lock and never resolves its callback's promise will hold that lock indefinitely. All callers waiting on the same name remain queued forever. This is by design — the API models mutual exclusion correctly, and the specification leaves timeout behavior to the caller.

In MCP servers, the starvation attack surface appears when the lock callback performs work that is proportional to the size or complexity of tool input, when the callback performs a network request whose response time the attacker can influence, or when tool output can influence which lock name is acquired. If an attacker can cause a tool call to acquire a lock and then drive its callback to take an arbitrarily long time — by providing a carefully crafted large input that triggers quadratic processing, or by causing the callback to make a network request to an attacker-controlled endpoint that responds slowly — every subsequent tool call waiting on the same lock name will queue indefinitely.

// VULNERABLE: no timeout on lock acquisition, callback duration unbounded
async function writeToolOutput(sessionId, toolName, content) {
  // Lock name scoped to session and tool — reasonable so far
  const lockName = `tool-output:${sessionId}:${toolName}`;

  await navigator.locks.request(lockName, async (lock) => {
    // PROBLEM 1: if content is attacker-controlled and large,
    // processContent() may run for seconds, holding the lock
    const processed = await processContent(content);

    // PROBLEM 2: if the network request stalls (attacker controls the endpoint),
    // this await never resolves — lock held indefinitely, queue grows unbounded
    await fetch('/api/store-output', {
      method: 'POST',
      body: JSON.stringify({ sessionId, toolName, processed })
    });
  });
}

// CORRECT: AbortSignal.timeout() on lock acquisition, separate timeout on callback work
async function writeToolOutputSafe(sessionId, toolName, content) {
  const lockName = `tool-output:${sessionId}:${toolName}`;

  // Timeout on waiting to acquire the lock
  const acquireSignal = AbortSignal.timeout(3000);  // give up after 3s if lock not available

  try {
    await navigator.locks.request(lockName, { signal: acquireSignal }, async (lock) => {
      // Timeout on the work inside the lock callback
      const processController = new AbortController();
      const workTimeout = setTimeout(() => processController.abort(), 8000);

      try {
        const processed = await processContent(content, { signal: processController.signal });

        await fetch('/api/store-output', {
          method: 'POST',
          body: JSON.stringify({ sessionId, toolName, processed }),
          signal: AbortSignal.timeout(5000)  // network request timeout
        });
      } finally {
        clearTimeout(workTimeout);
      }
      // Returning here releases the lock — even if processContent threw
    });
  } catch (err) {
    if (err.name === 'AbortError') {
      // Lock was not available within 3 seconds
      throw new ToolError('LOCK_TIMEOUT', 'Tool serialization lock unavailable — try again');
    }
    throw err;
  }
}

The second vector is lock name injection: if any part of the lock name string is derived from tool output, tool arguments, or user-supplied input without validation, an attacker can craft a lock name that collides with an existing lock, deliberately starving a different operation. Lock names must be constructed from values that the application controls — session IDs generated by the server, tool names from a fixed allowlist — never from values passed in tool parameters.

// VULNERABLE: lock name derived from tool argument — attacker controls which lock is acquired
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name: toolName, arguments: args } = request.params;

  // If args.resourceId is "file-write:admin", this acquires the admin file-write lock
  // blocking legitimate admin file-write operations
  const lockName = `resource:${args.resourceId}`;  // INJECTION POINT

  await navigator.locks.request(lockName, async (lock) => {
    return performOperation(args);
  });
});

// CORRECT: lock name constructed from validated, server-controlled values only
const TOOL_LOCK_ALLOWLIST = new Set(['file-write', 'cache-rebuild', 'db-migrate']);

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name: toolName, arguments: args } = request.params;

  // Lock name uses only the tool name (from a fixed set) and the session ID
  // (generated by the server, not provided by the client)
  if (!TOOL_LOCK_ALLOWLIST.has(toolName)) {
    throw new McpError(ErrorCode.InvalidRequest, `Unknown tool: ${toolName}`);
  }

  const lockName = `tool:${toolName}:${request.sessionId}`;  // server-controlled values only

  await navigator.locks.request(
    lockName,
    { signal: AbortSignal.timeout(5000) },
    async (lock) => performOperation(args)
  );
});

Cross-tab lock contention as a user-activity oracle

Because the Web Locks API is per-origin rather than per-tab, an attacker who has an open page at the same origin can observe which locks are held by timing lock acquisition attempts. When a target user is actively using the application in another tab, some named locks will be held during tool execution. The attacker's tab requests the same lock names. The time from requesting to acquiring the lock encodes the duration the holder had the lock, which in turn encodes information about what the user was doing.

Consider an MCP client that holds a lock named tool:file-write:user-12345 while a file write tool call is in progress. The lock hold duration is proportional to the file being written. An attacker tab at the same origin requesting that lock measures the hold time. Repeated sampling reveals the rhythm of the target user's tool calls: when they are active, how long each operation takes, and the approximate size of files being processed. This is a privacy violation and a reconnaissance channel even without any lock name encoding explicit data.

// Attacker code in a tab at the same origin as the MCP client:
async function probeUserActivity(targetUserId) {
  const results = [];

  for (const toolName of ['file-write', 'db-query', 'code-execute', 'web-fetch']) {
    const lockName = `tool:${toolName}:${targetUserId}`;  // guessed from naming convention

    const start = performance.now();

    // navigator.locks.request with ifAvailable: true does NOT queue —
    // it acquires if free, returns null if held.
    // This gives a boolean: "is the target currently running this tool?"
    await navigator.locks.request(lockName, { ifAvailable: true }, async (lock) => {
      if (lock === null) {
        // Lock is currently held — target is actively running this tool right now
        results.push({ toolName, active: true, sampledAt: Date.now() });
      } else {
        // Lock is free — tool not currently running
        results.push({ toolName, active: false, sampledAt: Date.now() });
      }
    });

    await new Promise(r => setTimeout(r, 100));
  }

  return results;
}

// Calling this every 500ms from a background tab produces a real-time activity timeline
// for any user whose ID can be guessed or derived from the lock naming convention

The defense is per-session lock naming with server-generated, unpredictable session identifiers — not user IDs. A lock named tool:file-write:sess_8f3a2b9c4d1e7f0a where the session token is a random 128-bit value cannot be guessed by an attacker. The session token must be generated by the server, not derived from the user's identity, path, or any other enumerable value.

navigator.locks.query() as a live activity enumeration channel

navigator.locks.query() returns a snapshot of all currently held and pending locks across the entire origin: { held: [{ name, mode, clientId }], pending: [{ name, mode, clientId }] }. Every tab at the same origin can call this method at any time and receive the complete list of active lock names. There is no permission gate, no per-tab scoping, and no way for a lock holder to opt out of appearing in the query result.

If lock names encode user-identifying information — as they do when the naming convention includes user IDs, email addresses, session tokens derived from predictable values, or operation identifiers that reveal what the user is doing — then navigator.locks.query() is a complete, real-time directory of every active user session and every tool currently being executed across the entire application. An attacker tab polling this API every 100 milliseconds receives a continuous stream of activity data with no rate limit and no authentication.

// What an attacker can do with navigator.locks.query() if lock names encode session data:

async function enumerateActiveSessions() {
  const { held, pending } = await navigator.locks.query();

  const activeSessions = [];

  for (const lock of held) {
    // Pattern: tool:<toolName>:user-<userId>
    const match = lock.name.match(/^tool:(\w+):user-(\d+)$/);
    if (match) {
      activeSessions.push({
        tool: match[1],        // which tool the user is running
        userId: match[2],      // the user's ID — directly from the lock name
        clientId: lock.clientId  // browser tab identifier
      });
    }
  }

  return activeSessions;
  // Returns: all users currently running any tool in the application,
  // what tool each is running, and which browser tab they are using.
  // No authentication required — any tab at the origin can call this.
}

// Defensive pattern: lock names must NOT encode user-identifying information
// Use opaque, server-generated session tokens that cannot be reversed to user IDs

function generateLockName(sessionToken, toolName) {
  // sessionToken is a random 256-bit value assigned by the server at session creation
  // It has no relationship to user ID, email, or any other user-identifying data
  // navigator.locks.query() will show this lock, but the name reveals nothing
  return `t:${toolName}:${sessionToken.slice(0, 16)}`;
  // Result: "t:file-write:8f3a2b9c4d1e7f0a" — reveals the tool but not the user
}

For the highest-sensitivity applications, consider whether the Web Locks API is the right primitive at all. If locking only needs to coordinate within a single tab (not across tabs or workers), a simple JavaScript mutex using Promise chaining accomplishes the same thing without the cross-origin lock namespace exposure:

// In-tab mutex — not visible to other tabs or via navigator.locks.query()
class TabLocalMutex {
  #queue = Promise.resolve();

  async withLock(fn) {
    let release;
    const gate = new Promise(r => { release = r; });

    // Chain this acquisition onto the existing queue
    const prev = this.#queue;
    this.#queue = prev.then(() => gate);

    // Wait for our turn
    await prev;

    try {
      return await fn();
    } finally {
      // Release the lock — allows the next queued caller to proceed
      release();
    }
  }
}

// Usage: invisible to other tabs, no lock name in the global namespace
const fileWriteMutex = new TabLocalMutex();

async function writeFile(path, content) {
  return fileWriteMutex.withLock(async () => {
    // Exclusive access within this tab — no cross-tab visibility
    const handle = await getFileHandle(path);
    const writable = await handle.createWritable();
    await writable.write(content);
    await writable.close();
  });
}

Cross-tenant lock name collisions in shared deployments

Multi-tenant MCP server deployments that serve all tenants from the same browser origin — the common case for SaaS products where the URL structure is https://app.example.com/tenant/acme/ and https://app.example.com/tenant/widgets-co/ — share a flat Web Locks namespace. A lock named cache:rebuild acquired by a tool call in tenant Acme's session blocks the same-named lock for tenant Widgets Co. This is cross-tenant interference: one tenant's activity degrades another tenant's performance, and a malicious tenant can deliberately starve other tenants by holding common lock names indefinitely.

The fix is mandatory per-tenant lock name prefixing, where the prefix is a server-assigned identifier that cannot be provided or manipulated by tool arguments or client-side code. The tenant ID used as a lock prefix must come from the authenticated session, not from the URL path, query string, or any user-supplied input.

// VULNERABLE: lock names shared across all tenants
// Any tenant holding "cache:rebuild" blocks all other tenants' cache rebuilds
async function rebuildCache(toolArgs) {
  await navigator.locks.request('cache:rebuild', { signal: AbortSignal.timeout(30000) }, async (lock) => {
    await performCacheRebuild(toolArgs.scope);
  });
}

// ALSO VULNERABLE: using URL path as the tenant prefix
// Attacker can craft a URL to match another tenant's path prefix
function buildLockName(name) {
  const tenantFromUrl = window.location.pathname.split('/')[2];  // from URL — attacker-controlled
  return `${tenantFromUrl}:${name}`;  // UNSAFE
}

// CORRECT: tenant ID comes from the authenticated session token, server-assigned
// The session context is established at login and cannot be overridden by client input

class MCPSessionContext {
  constructor(private readonly tenantId: string, private readonly sessionToken: string) {
    // tenantId and sessionToken are received from the server in the authenticated session response
    // They are stored in the session context, not in the URL or in any client-settable storage
  }

  buildLockName(purpose: string): string {
    // Lock name: tenant prefix (server-assigned) + session token prefix (opaque) + purpose
    // Example: "t_acme:sess_8f3a:cache:rebuild"
    // The tenant prefix is controlled by the server — no URL or arg injection possible
    return `t_${this.tenantId}:sess_${this.sessionToken.slice(0, 8)}:${purpose}`;
  }

  async withLock<T>(purpose: string, fn: () => Promise<T>): Promise<T> {
    const lockName = this.buildLockName(purpose);
    const signal = AbortSignal.timeout(10000);

    try {
      let result: T;
      await navigator.locks.request(lockName, { signal }, async (lock) => {
        result = await fn();
      });
      return result!;
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        throw new Error(`Lock "${purpose}" could not be acquired within 10 seconds`);
      }
      throw err;
    }
  }
}

// Usage — tenantId comes from the authenticated session, not from any argument
const ctx = new MCPSessionContext(session.tenantId, session.token);
await ctx.withLock('cache:rebuild', () => performCacheRebuild());

AbortSignal timeout for lock acquisition

Every navigator.locks.request() call that can block — any call that does not use ifAvailable: true — must carry an AbortSignal with a timeout. Without one, the caller will wait indefinitely for the lock to become available. In an MCP client, this means that a single stale lock holder (a tool call that crashed mid-execution, a tab that was closed while a lock was held, or a malicious tool that deliberately holds a lock) will block all subsequent callers of the same tool on the same session forever, creating a silent denial of service that appears as the application hanging.

// The minimal correct pattern for every navigator.locks.request() in MCP client code:

async function withLockTimeout<T>(
  lockName: string,
  timeoutMs: number,
  callback: (lock: Lock) => Promise<T>
): Promise<T> {
  const signal = AbortSignal.timeout(timeoutMs);

  try {
    let result: T;
    await navigator.locks.request(lockName, { signal }, async (lock) => {
      result = await callback(lock);
    });
    return result!;
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      // The lock was not available within timeoutMs milliseconds
      // This is distinct from the lock callback throwing — log separately
      console.warn(`[lock] Acquisition timed out after ${timeoutMs}ms for: ${lockName}`);
      throw new ToolError(
        'LOCK_ACQUISITION_TIMEOUT',
        `Tool could not acquire exclusive access within ${timeoutMs / 1000}s. ` +
        `Another operation may be stalled. Retry or reload.`
      );
    }
    throw err;  // Rethrow — error came from inside the callback
  }
}

// Lock timeout budget guidelines for MCP tool calls:
//  - UI-blocking operations (e.g., saving state before navigation): 2–3 seconds
//  - Tool serialization locks (preventing concurrent tool calls): 5–10 seconds
//  - Background work (cache rebuild, index refresh): 30–60 seconds
//  - Never exceed 60 seconds without a user-visible progress indicator and cancel affordance

// Detecting stale lock holders via query (for diagnostic tooling only — not a security control):
async function detectStaleLocks(prefix: string, maxAgeMs: number): Promise<string[]> {
  const { held } = await navigator.locks.query();
  // Note: the query result does not include acquisition timestamps —
  // you must track those yourself if you need stale detection
  return held
    .filter(lock => lock.name.startsWith(prefix))
    .map(lock => lock.name);
}
Risk Attack vector Defense
Lock starvation DoS Attacker holds a lock indefinitely via slow input or network stall AbortSignal.timeout() on acquisition + timeout on callback work
Lock name injection Tool argument influences lock name, targeting another session's lock Lock names constructed from server-controlled values only
Cross-tab contention oracle Attacker tab times lock acquisition to infer target user's activity Per-session opaque prefixes (not user IDs) in lock names
Session enumeration via query() Attacker calls navigator.locks.query() to list all active sessions Lock names must not contain user IDs or any enumerable identity data
Cross-tenant contention One tenant's lock blocks identically-named lock for another tenant Mandatory server-assigned tenant prefix in all lock names
Tool output exposed query results Tool returns navigator.locks.query() output, leaking other sessions' lock names Never include locks.query() output in MCP tool responses

SkillAudit findings for Web Locks API misuse

Critical Lock name derived from tool output or user-controlled parameter. The string passed to navigator.locks.request() is constructed using a tool argument, query parameter, or any value that originates from client-side input rather than server-assigned session context. Attacker controls which lock is acquired. Grade impact: −24.
Critical No AbortSignal timeout on lock acquisition. navigator.locks.request() called without a signal option. A stale lock holder — including one produced by a crashed tool call, a closed tab, or a deliberately malicious lock hold — blocks all subsequent callers on the same lock name indefinitely, creating an unrecoverable stall. Grade impact: −22.
High Lock names contain user IDs or session tokens derived from user identity. Lock names visible via navigator.locks.query() include user IDs, email addresses, or session identifiers that reveal which users are currently active and what tools they are running. Any same-origin tab can enumerate this data without authentication. Grade impact: −20.
High No per-tenant lock name prefix in multi-tenant deployment. Multiple tenants sharing the same browser origin use lock names drawn from the same flat namespace. A lock held by one tenant's tool call blocks identically-named locks for all other tenants, enabling cross-tenant interference and targeted starvation DoS. Grade impact: −18.
Medium navigator.locks.query() result exposed to tool output. A tool handler calls navigator.locks.query() and includes the held or pending lock names in the MCP tool response. The LLM or client receives a live snapshot of every active lock across the origin, including those belonging to other users or sessions. Grade impact: −12.
Medium No lock acquisition timeout monitoring or alerting. Lock acquisition timeouts are caught and surfaced to the user as generic errors, but no structured logging or alerting is in place. Lock starvation DoS targeting a session or tenant produces no signal visible to operators. Grade impact: −10.

Audit your MCP server for these issues

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

Run a free audit →