Topic: zero-trust architecture
MCP server zero-trust architecture — verify every call, assume breach, and least-privilege tool design
Zero-trust is not an enterprise buzzword for MCP servers — it is the correct description of the threat model. The caller is an LLM whose reasoning can be manipulated by adversarial content. The tool arguments are strings constructed by that LLM with no guarantee of intent. The session is a long-running process that accumulates context an attacker can influence across multiple turns. Never trust; always verify. Five architectural patterns that apply zero-trust principles at each layer of an MCP server.
1. Per-call argument verification, not session-level trust
The most common zero-trust violation in MCP servers is trusting tool arguments because the session was authenticated. Session authentication answers "is this caller allowed to use this server?", not "is this specific tool call safe to execute?". In a multi-turn session where the LLM has processed adversarial content, each subsequent tool call must be treated with the same skepticism as the first.
// Zero-trust validation: every call, every argument
const deleteFileSchema = z.object({
// Strictly typed: reject anything that doesn't match
filePath: z.string()
.max(500)
// Reject path traversal attempts
.refine(p => !p.includes('..'), 'path traversal rejected')
// Reject absolute paths (MCP servers should work in scoped directories)
.refine(p => !p.startsWith('/'), 'absolute path rejected')
// Reject null bytes
.refine(p => !p.includes('\0'), 'null byte rejected'),
})
server.tool('delete_file', deleteFileSchema, async ({ filePath }) => {
// Zero-trust: re-validate against allowed directory even after schema check
const resolved = path.resolve(ALLOWED_ROOT, filePath)
if (!resolved.startsWith(ALLOWED_ROOT + path.sep)) {
throw new Error('SECURITY: path resolves outside allowed root')
}
// Zero-trust: check the file type matches expectations
const stat = await fs.stat(resolved)
if (!stat.isFile()) {
throw new Error('SECURITY: target is not a regular file')
}
return fs.unlink(resolved)
})
2. Assume-breach defense in depth
"Assume breach" means designing each layer of your MCP server as if all other layers have already failed. The network layer might be compromised — so the application layer must validate. The application layer might be compromised — so the OS-level container isolation must hold. The container might be compromised — so credential scope must be minimum. Each layer is hardened independently, not relying on prior layers to have prevented the attack.
// Defense in depth: five independent layers for a single file operation
// Layer 1: Schema validation (Zod)
const schema = z.object({ filePath: z.string().max(500) })
// Layer 2: Application-layer path validation (code)
const resolved = path.resolve(ALLOWED_ROOT, filePath)
if (!resolved.startsWith(ALLOWED_ROOT)) throw new Error('path escape')
// Layer 3: OS-level filesystem permissions (server runs as non-root user
// with access only to the working directory — Dockerfile USER directive)
// Layer 4: Container isolation (Docker read_only + tmpfs, no host volume mounts)
// Layer 5: Credential scope (even if layers 1-4 fail, the OS credential
// cannot access files outside its IAM-scoped S3 bucket or similar)
// If layer 1 fails (schema bypass), layer 2 catches it.
// If layer 2 fails (logic error), layer 3 catches it.
// If layer 3 fails (privilege escalation), layer 4 limits blast radius.
// If layer 4 fails (container escape), layer 5 limits credential reach.
// No single failure leads to total compromise.
3. Least-privilege per-operation scope
Least-privilege in a zero-trust MCP server means more than requesting minimum credential scope at registration — it means enforcing minimum scope at the operation level. A tool that can read or write should default to read and require an explicit parameter or elevated check to write. A credential with full repository access should only use the subset needed for each specific tool call.
// ANTI-PATTERN: blanket write capability
server.tool('github_operation', {
operation: z.enum(['read', 'write', 'delete']),
repo: z.string(),
}, async ({ operation, repo }) => {
// Any call can escalate to delete — the LLM just needs to say "operation: delete"
if (operation === 'delete') return deleteRepo(repo)
})
// ZERO-TRUST: separate tools with separate scopes and separate validation
server.tool('get_repo', { repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/) }, async ({ repo }) => {
// Read only — no way to escalate to write from this tool
return githubReadClient.get(`/repos/${repo}`)
})
server.tool('create_branch', {
repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
branchName: z.string().max(100).regex(/^[\w.-]+$/),
}, async ({ repo, branchName }) => {
// Write scope, but limited to branch creation — no delete path
return githubWriteClient.post(`/repos/${repo}/git/refs`, { ref: `refs/heads/${branchName}` })
})
4. Lateral movement prevention
In multi-agent architectures, a compromised MCP server could attempt to call other MCP servers — directly (if it has a client implementation) or by crafting tool results that cause the LLM to invoke other servers. Preventing lateral movement means ensuring your MCP server cannot act as an orchestrator that calls other MCP endpoints.
// Lateral movement prevention: never include MCP client code in an MCP server
// ❌ WRONG: MCP server that calls another MCP server
import { Client } from '@modelcontextprotocol/sdk/client'
server.tool('orchestrate', { task: z.string() }, async ({ task }) => {
const otherServer = new Client(...) // This server is now an attack pivot point
return otherServer.callTool('execute', { command: task })
})
// ✅ CORRECT: MCP server calls external REST APIs, not other MCP servers
server.tool('search_code', { query: z.string().max(200) }, async ({ query }) => {
// Direct API call — no MCP client, no server-to-server MCP calls
return safeFetch(`https://api.github.com/search/code?q=${encodeURIComponent(query)}`)
})
// ✅ CORRECT: If multi-step coordination is needed, return structured data
// and let the LLM decide the next action — don't coordinate server-to-server
server.tool('analyze_repo', { repo: z.string() }, async ({ repo }) => {
const data = await safeFetch(`https://api.github.com/repos/${repo}`)
// Return structured data; the LLM will call follow-up tools as needed
return { name: data.name, topics: data.topics, language: data.language }
})
5. Explicit-allow-only upstream API policy
The zero-trust equivalent of network allowlisting at the code layer: rather than allowing any API call that a credential makes possible, each tool handler explicitly documents and limits what API calls it will make. This isn't just SSRF prevention — it's the application-layer policy that enforces least-privilege even when the credential scope is broader than needed.
// Explicit-allow API call policy: each tool declares its allowed API calls
const TOOL_API_POLICY: Record<string, readonly string[]> = {
'list_repos': ['GET /user/repos', 'GET /orgs/{org}/repos'],
'get_file': ['GET /repos/{owner}/{repo}/contents/{path}'],
'create_issue': ['POST /repos/{owner}/{repo}/issues'],
// NOT in policy = NOT allowed, even if credential has permission
}
function createPolicyEnforcedClient(toolName: string, baseToken: string) {
const allowedPatterns = TOOL_API_POLICY[toolName]
if (!allowedPatterns) throw new Error(`No API policy for tool: ${toolName}`)
return {
async request(method: string, path: string, body?: unknown) {
const pattern = `${method} ${path.replace(/[a-z0-9_-]{20,}/g, '{id}')}`
const isAllowed = allowedPatterns.some(p =>
new RegExp('^' + p.replace(/\{[^}]+\}/g, '[^/]+') + '$').test(pattern)
)
if (!isAllowed) {
emitSecurityEvent({ type: 'unexpected_endpoint', severity: 'high',
detail: `${toolName} tried to call ${method} ${path} — not in policy` })
throw new Error(`SECURITY: ${toolName} is not authorized to call ${method} ${path}`)
}
return safeFetch(`https://api.github.com${path}`, {
method, headers: { Authorization: `Bearer ${baseToken}` },
body: body ? JSON.stringify(body) : undefined
})
}
}
}
How zero-trust maps to SkillAudit sub-scores
Zero-trust principles span multiple SkillAudit sub-scores because they address different layers of the security model:
- Security sub-score: Per-call argument validation, SSRF prevention (network-level zero-trust), path traversal prevention. Missing any of these generates Security findings regardless of session-level authentication.
- Permissions Hygiene sub-score: Least-privilege tool design, separate tools per operation scope, no blanket write access in read-only tools. Over-scoped tool registration generates Permissions findings.
- Credential Exposure sub-score: Assume-breach credential handling — credentials loaded per-request, not at startup; no fallback to hardcoded values. Credential mishandling generates Exposure findings.
- Documentation Completeness sub-score: Zero-trust requires documenting the trust model in SECURITY.md — what the server trusts, what it doesn't, and what the known limitations are. Missing trust model documentation is a Documentation finding.
For the multi-agent context where zero-trust matters most, see MCP security in multi-agent pipelines. For the SSRF component of network-level zero-trust, see MCP server network segmentation. For the credential handling component, see API key rotation security.
Assess your server's zero-trust posture
SkillAudit grades your MCP server across Security, Permissions, Credentials, and Documentation — the four axes of zero-trust compliance.
Run a free audit