Topic: process isolation security
MCP server process isolation security — safe subprocess spawning, stdio authentication, and privilege drop after startup
Most MCP servers run as child processes — either launched by a Claude client over stdio, or themselves spawning subprocess tools to perform work. Child processes inherit more from their parent than most developers realize: environment variables (including credentials), open file descriptors, signal handlers, and sometimes elevated privileges. Five patterns that establish proper process isolation boundaries for stdio-based and subprocess-spawning MCP servers.
1. spawn vs fork — why fork is dangerous for subprocess tools
Node.js offers two primary ways to create child processes: child_process.spawn and child_process.fork. The difference matters for security. fork creates a new V8 process but keeps it in the same Node.js module environment, shares file descriptors (including any open credential sockets), and allows bidirectional IPC over a shared channel. spawn creates a clean process with only the explicitly passed environment variables and no inherited file descriptor sharing beyond stdio. For MCP servers that run subprocess tools — code execution engines, shell utilities, analysis scripts — always use spawn with an explicit, minimal environment. Assume the subprocess may be compromised and design accordingly.
import { spawn } from 'child_process'
// ANTI-PATTERN: fork with inherited environment
// import { fork } from 'child_process'
// const child = fork('./worker.js') // inherits all env vars including credentials
// SAFE: spawn with explicit minimal environment
function spawnSandboxed(command: string, args: string[]) {
return spawn(command, args, {
// Pass only what the subprocess genuinely needs — no credential env vars
env: {
PATH: '/usr/local/bin:/usr/bin:/bin', // Explicit PATH, no extras
NODE_ENV: 'production',
// Do NOT pass: AWS_SECRET_*, GITHUB_TOKEN, DATABASE_URL, etc.
},
// Isolate stdio — subprocess only gets stdin/stdout/stderr
stdio: ['pipe', 'pipe', 'pipe'],
// Run in a restricted working directory
cwd: '/tmp/sandbox',
// Detach prevents subprocess from receiving parent's signals
detached: false,
})
}
2. stdio channel authentication without ambient environment trust
An MCP server launched over stdio by a Claude client receives its input on stdin and sends output on stdout. A common mistake is to use environment variables for authentication — a secret token in MCP_AUTH_TOKEN that the MCP client was supposed to set. The problem is that environment variables are visible to any process that can read /proc/[pid]/environ on Linux (readable by the process owner), may be logged by process supervisors, and most importantly cannot be verified: the MCP server cannot know that those environment variables came from the legitimate Claude client rather than from an attacker who compromised the launch environment. Prefer explicit authentication via the MCP initialization handshake.
import { z } from 'zod'
import { createHmac } from 'crypto'
// ANTI-PATTERN: ambient environment variable trust
// const authToken = process.env.MCP_AUTH_TOKEN // Cannot verify provenance
// EXPLICIT AUTH: validate a signed handshake in the MCP initialize call
const InitializeParamsSchema = z.object({
protocolVersion: z.string(),
clientInfo: z.object({
name: z.string(),
version: z.string(),
}),
// Custom extension: signed nonce for server-side verification
_auth: z.object({
nonce: z.string().length(32),
timestamp: z.number().int(),
signature: z.string().length(64),
}).optional(),
})
function verifyInitHandshake(auth: { nonce: string; timestamp: number; signature: string }): boolean {
const serverSecret = process.env.MCP_HMAC_SECRET!
const age = Date.now() - auth.timestamp
if (age > 30_000 || age < 0) return false // Reject stale or future timestamps
const expected = createHmac('sha256', serverSecret)
.update(`${auth.nonce}:${auth.timestamp}`)
.digest('hex')
return expected === auth.signature
}
3. Subprocess resource limits — maxBuffer and OS-level constraints
A subprocess that runs an LLM-influenced command can produce unbounded output. Without limits, this exhausts process memory or hangs the parent MCP server indefinitely. Node.js's execFile and exec accept a maxBuffer option — when output exceeds this, the process is killed and an error is thrown. The default is 1 MB for exec and unlimited for spawn with piped stdio. Explicit limits must be set. Pair these application-level limits with OS-level constraints using ulimit equivalents in Node.js via resource-limiting libraries or by wrapping the subprocess in a shell with ulimit settings pre-applied.
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const MAX_OUTPUT_BYTES = 512_000 // 512 KB output limit
const EXEC_TIMEOUT_MS = 10_000 // 10-second wall clock timeout
async function runSubprocessTool(binary: string, args: string[]): Promise<string> {
// Allowlist: never exec arbitrary paths
const ALLOWED_BINARIES = new Set(['/usr/bin/git', '/usr/bin/grep'])
if (!ALLOWED_BINARIES.has(binary)) throw new Error('Binary not in allowlist')
const { stdout } = await execFileAsync(binary, args, {
maxBuffer: MAX_OUTPUT_BYTES,
timeout: EXEC_TIMEOUT_MS,
killSignal: 'SIGKILL', // Don't give the process time to respond to SIGTERM
env: { PATH: '/usr/bin:/bin' },
// ulimit wrapper: run under 'prlimit' for hard resource limits
// args become: ['--nproc=50', '--nofile=64', '--', binary, ...args]
})
if (stdout.length > MAX_OUTPUT_BYTES) {
// Extra guard in case maxBuffer allows slightly over
throw new Error('Output exceeded size limit')
}
return stdout
}
4. IPC message schema validation before dispatching subprocess responses
When an MCP server spawns a subprocess and receives data back — whether over Node.js IPC, piped stdio, or a Unix socket — that data must be treated as untrusted input. The subprocess may be compromised, may have produced corrupted output due to a bug, or may be responding to an injection in the command arguments. Every message received from a subprocess must be parsed and validated against an explicit schema before the parent process acts on it. The parent trusting the subprocess output without validation creates a second-order injection path: an attacker who can influence subprocess output can influence parent process behavior.
import { z } from 'zod'
import { spawn } from 'child_process'
// Strict schema for subprocess IPC messages
const SubprocessResultSchema = z.object({
status: z.enum(['ok', 'error']),
data: z.record(z.unknown()).optional(),
error: z.string().max(500).optional(),
}).strict() // .strict() rejects keys not in the schema
function runSubprocessWithValidation(args: string[]): Promise<z.infer<typeof SubprocessResultSchema>> {
return new Promise((resolve, reject) => {
const child = spawn('/usr/bin/analyzer', args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { PATH: '/usr/bin:/bin' },
})
let stdout = ''
const maxLen = 65_536
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
// Kill subprocess if output grows too large
if (stdout.length > maxLen) {
child.kill('SIGKILL')
reject(new Error('Subprocess output too large'))
}
})
child.on('close', (code) => {
let raw: unknown
try { raw = JSON.parse(stdout) }
catch { reject(new Error('Subprocess returned invalid JSON')); return }
const result = SubprocessResultSchema.safeParse(raw)
if (!result.success) {
reject(new Error('Subprocess response failed schema validation'))
return
}
resolve(result.data)
})
})
}
5. Privilege drop after startup — setuid/setgid in Node.js
Some MCP servers must start with elevated privileges: binding to port 443, reading a TLS private key stored at a root-only path, or initializing a hardware security module. Once the privileged initialization is complete, continuing to run with those privileges is unnecessary and dangerous. Node.js exposes process.setuid() and process.setgid() to drop to a lower-privilege user after startup. On Linux, this is equivalent to the classic Unix pattern of binding a privileged port then calling setuid(nobody). After the drop, even if the process is compromised, the attacker cannot perform privileged operations. This only works when the server is not containerized as root — combine with a non-root container user for defense in depth.
import { createServer } from 'https'
import { readFileSync } from 'fs'
// Must be called before privilege drop
const tlsKey = readFileSync('/etc/ssl/private/mcp.key') // root-only readable
const tlsCert = readFileSync('/etc/ssl/certs/mcp.crt')
const server = createServer({ key: tlsKey, cert: tlsCert })
// Bind privileged port 443 — requires root
server.listen(443, () => {
// Drop privileges immediately after bind
if (process.getuid && process.getuid() === 0) {
// Drop group first (must happen before uid drop — after uid drop, we lose setgid permission)
process.setgid!('nogroup') // GID of unprivileged group
process.setuid!('nobody') // UID of unprivileged user
// Verify the drop succeeded
if (process.getuid() === 0) {
console.error('FATAL: privilege drop failed — shutting down')
process.exit(1)
}
console.log(`Running as uid=${process.getuid()} gid=${process.getgid()}`)
}
})
How process isolation maps to SkillAudit sub-scores
Process isolation vulnerabilities are among the most impactful findings because they can lead to full server compromise through a single tool call:
- Security sub-score: Using fork instead of spawn, ambient environment variable trust, and missing subprocess output limits all generate Security findings. Subprocess injection paths discovered through missing IPC validation are flagged as critical.
- Permissions Hygiene sub-score: Running as root throughout the process lifetime generates a Permissions finding. Over-privileged subprocess environments — passing DATABASE_URL or API keys to subprocesses that do not need them — are also flagged.
- Credential Exposure sub-score: Environment variables containing credentials that are inherited by child processes generate Credential Exposure findings. This includes implicit inheritance via fork.
For the container-level isolation that complements process isolation, see MCP server zero-trust architecture. For credential handling patterns that reduce what subprocesses can inherit, see API key rotation security.
Audit your MCP server's process isolation posture
SkillAudit detects unsafe subprocess patterns, ambient environment trust, missing resource limits, and over-privileged process configurations in MCP server implementations.
See pricing