MCP Server Security in Production 2026
Most teams skip MCP security: API keys in plaintext, no rate limiting, no audit logging, no key rotation. Production checklist and code examples.
Most teams deploying MCP servers in production skip fundamental security practices. A post in r/mcp documented what teams are skipping: API key exposure in plaintext config files, no rate limiting per user, no audit logging for tool calls, and no key rotation policy. These are not edge cases. They are baseline requirements that the MCP ecosystem has not enforced.
API key exposure in config files
The standard MCP configuration puts API keys directly in JSON config files. These files get committed to repositories, shared in documentation, and copied between environments without sanitization.
{
"mcpServers": {
"search": {
"command": "npx",
"args": ["-y", "scavio-search-mcp"],
"env": {
"SCAVIO_API_KEY": "sk-live-abc123..."
}
}
}
}The fix: use environment variable references instead of inline values. Most MCP clients support environment variable expansion. For production deployments, use a secrets manager.
# Store keys in environment, not config files
export SCAVIO_API_KEY="sk-live-abc123..."
# Config references the env var (no secret in file)
# The MCP client reads SCAVIO_API_KEY from the environmentAdd MCP config files to .gitignore. If they must be version-controlled, use a template with placeholder values and document the required environment variables separately.
No rate limiting per user
When multiple users or agents share an MCP server, any single user can exhaust the API quota. A runaway agent loop can burn through an entire monthly allocation in hours. Without per-user rate limits, there is no isolation between workloads.
import time
from collections import defaultdict
class RateLimiter:
"""Per-user rate limiting for MCP tool calls."""
def __init__(self, max_calls_per_minute=30, max_calls_per_hour=500):
self.max_per_minute = max_calls_per_minute
self.max_per_hour = max_calls_per_hour
self.calls = defaultdict(list)
def check(self, user_id: str) -> bool:
now = time.time()
# Clean old entries
self.calls[user_id] = [
t for t in self.calls[user_id]
if now - t < 3600
]
# Check hour limit
if len(self.calls[user_id]) >= self.max_per_hour:
return False
# Check minute limit
recent = [
t for t in self.calls[user_id]
if now - t < 60
]
if len(recent) >= self.max_per_minute:
return False
self.calls[user_id].append(now)
return True
# Usage in MCP tool handler
limiter = RateLimiter(max_calls_per_minute=30, max_calls_per_hour=500)
def handle_search_tool(user_id, query):
if not limiter.check(user_id):
return {"error": "Rate limit exceeded. Try again later."}
# Proceed with search
return execute_search(query)No audit logging
MCP tool calls execute with the permissions of the API keys configured in the server. Without audit logging, you cannot determine: which user triggered an expensive operation, whether an agent is making redundant calls, when a key was last used, or whether unauthorized tool access occurred.
import json, logging
from datetime import datetime
# Configure structured logging
logging.basicConfig(
filename="mcp_audit.log",
level=logging.INFO,
format="%(message)s"
)
def audit_log(user_id, tool_name, params, result_status):
"""Log every MCP tool call for audit."""
entry = {
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
"tool": tool_name,
"params": {k: v for k, v in params.items()
if k not in ("api_key", "token", "secret")},
"status": result_status,
}
logging.info(json.dumps(entry))
# Usage
audit_log("user-123", "web_search", {"query": "test"}, "success")
audit_log("user-456", "web_search", {"query": "test"}, "rate_limited")No key rotation policy
API keys in MCP configs tend to be set once and never changed. A compromised key stays valid indefinitely. The minimum rotation policy for production:
- Rotate keys every 90 days as a baseline
- Rotate immediately if any team member leaves
- Rotate immediately if a config file was accidentally committed to a public repository
- Use scoped keys with minimal permissions. A search-only key should not have access to account management endpoints
- Maintain a key inventory documenting which keys are deployed where and when they were last rotated
Production MCP security checklist
Before deploying any MCP server to production, verify each item:
- API keys stored in environment variables or secrets manager, not config files
- MCP config files added to .gitignore
- Per-user rate limiting implemented
- Audit logging for every tool call
- Key rotation schedule documented and enforced
- Scoped API keys with minimal permissions
- Input validation on all tool parameters (prevent injection)
- Error responses that do not leak API keys or internal paths
- Monitoring alerts for unusual usage patterns
- Incident response plan for compromised keys
Input validation matters
MCP tool parameters come from LLM outputs, which can be manipulated through prompt injection. Validate every parameter before passing it to external APIs. Reject inputs that contain suspicious patterns, enforce type constraints, and sanitize strings.
import re
def validate_search_query(query: str) -> str:
"""Sanitize search query from LLM output."""
if not isinstance(query, str):
raise ValueError("Query must be a string")
if len(query) > 500:
raise ValueError("Query too long")
if len(query) < 2:
raise ValueError("Query too short")
# Remove potential injection patterns
sanitized = re.sub(r'[<>{}]', '', query)
return sanitized.strip()The cost of skipping security
A leaked API key costs: the immediate financial exposure from unauthorized usage, the time to rotate keys across all deployed instances, the audit effort to determine what was accessed, and the trust damage if customer data was involved. Implementing the checklist above takes 2-4 hours. Recovering from a security incident takes weeks.