Securing Local and Remote MCP Servers: Token Auth, Sandboxing, and Least Privilege
S L Manikanta
Sep 13, 2026 • 7 min read
bolt Key Takeaways
- MCP servers run with the same OS permissions as the host process — any tool that reads files or runs commands is a potential privilege escalation vector.
- For remote MCP servers, always require Bearer token authentication on the SSE endpoint. Never expose an unauthenticated MCP server over a network.
- Sandbox local MCP servers in Docker with read-only mounts and a non-root user to limit filesystem blast radius.
- Implement a path allowlist in every file-access tool — validate the resolved path starts with your approved root before opening or writing any file.
Want to build production-ready AI?
Subscribe to StackMindset to receive actionable systems engineering checklists and code walkthroughs. No spam, only technical insights.
[!IMPORTANT] The core threat model: Your MCP server tools run with the OS permissions of the host process. If an LLM is manipulated (via prompt injection or a misconfigured system prompt) into calling a tool with a malicious argument, the consequences happen at the OS level — not inside a sandbox.
The minimum viable security posture for any production MCP server:
- Run as non-root
- Path allowlist on all file-access tools
- Token auth on all network-exposed SSE endpoints
- Rate limit tool calls per session
MCP’s power is also its risk surface. A tool that reads files, executes SQL, or calls external APIs operates at OS-level permissions. This guide covers the concrete security controls that go between “it works” and “it’s safe to deploy.”
Environment
| Package | Version |
|---|---|
fastmcp | 2.2.0 |
fastapi | 0.115+ |
python-jose | 3.3.0 |
docker | 26+ |
| Python | 3.11+ |
1. Threat Model
graph TD
LLM[LLM / Claude Code]
MCP[MCP Server]
Tools[Tools: read_file, run_sql, fetch_url]
OS[OS: Filesystem, Network, Processes]
LLM -->|tool call| MCP
MCP -->|invoke| Tools
Tools -->|syscalls| OS
Attacker1[Prompt Injection\nin fetched content]
Attacker2[Unauthorized\nSSE client]
Attacker3[Path traversal\n in tool args]
Attacker1 -.manipulates.-> LLM
Attacker2 -.connects to.-> MCP
Attacker3 -.exploits.-> Tools
Three attack surfaces to harden:
- Prompt injection — malicious content in tool outputs manipulates the LLM
- Unauthorized access — unauthenticated clients connecting to your SSE endpoint
- Tool argument injection — path traversal, SQL injection, or command injection via tool args
2. Path Allowlist for File-Access Tools
Every tool that reads or writes files must validate the resolved absolute path against an approved root directory before opening anything.
import pathlib
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
mcp = FastMCP("secure-server")
ALLOWED_ROOT = pathlib.Path("/workspace/data").resolve()
def _safe_path(user_path: str) -> pathlib.Path:
"""Resolve user-supplied path and verify it's within the allowed root."""
try:
target = (ALLOWED_ROOT / user_path).resolve()
except (ValueError, OSError) as e:
raise ToolError(f"Invalid path: {e}") from e
# The critical check — prevent directory traversal
if not str(target).startswith(str(ALLOWED_ROOT)):
raise ToolError(
f"Access denied: path resolves outside allowed root. "
f"Allowed root: {ALLOWED_ROOT}"
)
return target
@mcp.tool()
def read_file(relative_path: str) -> str:
"""Read a file from the data workspace. Path must be relative to /workspace/data."""
target = _safe_path(relative_path)
if not target.is_file():
raise ToolError(f"File not found: {relative_path}")
# Size limit prevents memory exhaustion
size_bytes = target.stat().st_size
if size_bytes > 5 * 1024 * 1024: # 5 MB
raise ToolError(f"File too large ({size_bytes // 1024} KB). Maximum is 5 MB.")
return target.read_text(encoding="utf-8", errors="replace")
@mcp.tool()
def write_file(relative_path: str, content: str) -> str:
"""Write content to a file in the data workspace."""
target = _safe_path(relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Written {len(content)} characters to {relative_path}"
The _safe_path() function resolves symlinks via pathlib.Path.resolve() before comparing against the allowed root. Without resolve(), a symlink outside the allowed directory passes a naive prefix check.
3. Token Authentication on SSE Endpoints
For remote MCP servers (SSE transport), mount FastMCP inside FastAPI and add bearer token validation:
import os
import logging
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastmcp import FastMCP
import jwt # python-jose
logger = logging.getLogger(__name__)
security = HTTPBearer()
mcp = FastMCP("authenticated-server")
# In production: load from env and rotate regularly
JWT_SECRET = os.environ["MCP_JWT_SECRET"]
JWT_ALGORITHM = "HS256"
def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
token = credentials.credentials
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
# Build the FastAPI app
app = FastAPI()
# Mount the MCP SSE endpoint with auth dependency
mcp_app = mcp.get_asgi_app()
@app.get("/sse", dependencies=[Depends(verify_token)])
async def sse_endpoint():
"""SSE endpoint — token verified by dependency."""
pass
# Mount MCP app under /mcp prefix, with the auth-protected /sse route
app.mount("/mcp", mcp_app)
For OAuth-based auth (e.g., verifying tokens issued by your OAuth provider):
import httpx
OAUTH_INTROSPECT_URL = os.environ["OAUTH_INTROSPECT_URL"]
OAUTH_CLIENT_ID = os.environ["OAUTH_CLIENT_ID"]
OAUTH_CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"]
async def verify_oauth_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
OAUTH_INTROSPECT_URL,
data={"token": credentials.credentials},
auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET),
)
if response.status_code != 200 or not response.json().get("active"):
raise HTTPException(status_code=401, detail="Invalid or expired token")
return response.json()
4. Docker Sandboxing for Local Servers
Even for local stdio servers, running in Docker provides a strong filesystem isolation boundary:
# Dockerfile
FROM python:3.11-slim
# Create a non-root user
RUN useradd --system --uid 1001 --no-create-home mcp-user
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
# Switch to non-root user before running
USER mcp-user
CMD ["python", "server.py"]
Register the Docker-based server in Claude Code config:
{
"mcpServers": {
"secure-docs-server": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"--read-only",
"--tmpfs", "/tmp:size=50m",
"--mount", "type=bind,source=/workspace/data,target=/workspace/data,readonly",
"--network", "none",
"--memory", "512m",
"--cpus", "1.0",
"your-mcp-server:latest"
]
}
}
}
Key Docker flags:
--read-only: root filesystem is read-only--tmpfs /tmp: provides a writable temp directory in RAM only--network none: blocks all outbound network access--memory 512m: prevents memory-based DoS--mount readonly: data directory is visible but not writable
5. Rate Limiting Tool Calls
Prevent runaway tool loops (from LLM bugs or prompt injection) with a per-session rate limiter:
import time
from collections import defaultdict
from threading import Lock
from fastmcp.exceptions import ToolError
class RateLimiter:
def __init__(self, max_calls: int, window_seconds: float):
self.max_calls = max_calls
self.window_seconds = window_seconds
self._calls: dict[str, list[float]] = defaultdict(list)
self._lock = Lock()
def check(self, session_id: str):
now = time.monotonic()
with self._lock:
calls = self._calls[session_id]
# Remove calls outside the window
cutoff = now - self.window_seconds
self._calls[session_id] = [t for t in calls if t > cutoff]
if len(self._calls[session_id]) >= self.max_calls:
raise ToolError(
f"Rate limit exceeded: maximum {self.max_calls} tool calls "
f"per {self.window_seconds}s window per session."
)
self._calls[session_id].append(now)
# 60 tool calls per minute per session
rate_limiter = RateLimiter(max_calls=60, window_seconds=60.0)
@mcp.tool()
def expensive_operation(session_id: str, data: str) -> str:
"""Perform an expensive operation. Rate-limited to 60 calls/minute."""
rate_limiter.check(session_id)
# ... actual work
return "completed"
6. Prompt Injection Defenses
| Attack Vector | Defense |
|---|---|
| Injected instructions in fetched web content | Sanitize fetched HTML to plain text before returning; never return raw HTML |
| SQL result rows containing LLM instructions | Wrap database results in a structured format: {"rows": [...]} — avoid returning raw strings |
| File content with embedded system prompt text | Mark tool output provenance clearly: prefix with [FILE CONTENT] so the LLM understands it’s data, not instructions |
| Tool descriptions that trigger other tools | Review all tool descriptions for ambiguous phrasing; avoid “if X then call Y” patterns in descriptions |
7. Tool Allowlist Per Session Role
Control which tools are available based on the authenticated user’s role:
ROLE_TOOLS = {
"reader": {"read_file", "search_docs", "list_directory"},
"writer": {"read_file", "write_file", "search_docs", "list_directory"},
"admin": None, # None = all tools
}
def get_allowed_tools(role: str) -> set[str] | None:
return ROLE_TOOLS.get(role, set())
# In the SSE handler, filter the tools list based on the verified token's role
Next Steps
For building the MCP server itself before hardening it, see Building High-Throughput Production MCP Servers with Python FastMCP and stdio.
The PriviPaste project demonstrates a local PII redaction MCP layer — an example of least-privilege tool design where the server only receives and returns sanitized content.
For securing LangGraph agents that consume MCP tool outputs, see AI Agent Security Best Practices.
Want to build production-ready AI?
Subscribe to StackMindset to receive actionable systems engineering checklists and code walkthroughs. No spam, only technical insights.
Written by S L Manikanta
AI Engineer specializing in agentic workflows, multi-step LLM validation pipelines, and secure cloud environments. Sharing practical lessons from building software.
Related Articles
What is an Agentic Loop? Claude Agent SDK Complete Reference (2026)
A complete technical reference on the Agentic Loop architecture, exploring the Claude Agent SDK lifecycle, context compaction, and how to build autonomous while-loops.
What is Model Context Protocol (MCP)? Complete Technical Reference (2026)
A definitive guide to the Model Context Protocol (MCP). Learn how Anthropic's open standard enables AI assistants to securely connect to external tools, databases, and APIs.
Building High-Throughput Production MCP Servers with Python FastMCP and stdio
Build a production-ready MCP server with FastMCP over stdio transport. Covers tool registration, schema validation, error handling, concurrency limits, and Claude Code integration.