OAuth 2.1 is the right default for MCP servers reached by end-user-facing agent clients, but it is heavier than necessary for pure service-to-service traffic inside a trusted network boundary. This lesson covers two lighter patterns, when each fits better than a full OAuth flow, and how to layer them with the authorization concepts from the surrounding lessons rather than treating any single mechanism as sufficient on its own.
API Keys with Hashed Comparison
API keys are the simplest authentication mechanism and remain appropriate for internal agent-to-MCP-server traffic where there is no individual end-user to authorize, just a service identity. The critical implementation detail is never comparing or storing plaintext keys.
import hashlib, hmac, os
# Loaded from a secrets manager at startup, never hardcoded, never logged
VALID_KEY_HASHES: set[str] = set(os.environ["VALID_KEY_HASHES"].split(","))
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
class APIKeyMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/health":
return await call_next(request)
api_key = request.headers.get("X-Agent-API-Key", "")
key_hash = _hash_key(api_key)
# Constant-time membership check avoids timing side channels
is_valid = any(
hmac.compare_digest(key_hash, valid_hash) for valid_hash in VALID_KEY_HASHES
)
if not is_valid:
return JSONResponse(status_code=401, content={"error": "invalid_api_key"})
return await call_next(request)
Rotate keys by adding the new hash to VALID_KEY_HASHES before removing the old one, this validates against a secrets-manager-backed set refreshed on an interval, so both keys work during the overlap window and no request is rejected during rotation.
import asyncio
async def refresh_valid_keys_periodically():
"""Poll the secrets manager every 60s so key rotation takes effect
without a server restart, old + new keys both valid during overlap."""
global VALID_KEY_HASHES
while True:
VALID_KEY_HASHES = await secrets_client.get_hash_set("mcp-api-keys")
await asyncio.sleep(60)
A rotation runbook worth writing down alongside this code: issue the new key, wait one full refresh interval (so every running server instance has picked it up), distribute the new key to the calling service, confirm via logs or metrics that traffic has shifted to the new key, then remove the old key’s hash from the secrets manager. Skipping the confirmation step and removing the old key too early is the most common way key rotation causes an unplanned outage, since a caller that has not yet redeployed with the new key will suddenly start failing every request.
Mutual TLS for Service-to-Service Traffic
When both the agent orchestrator and the MCP server are internal services under a company-controlled certificate authority, mTLS authenticates the connection itself: each side presents a certificate, and the TLS handshake fails if either certificate is not signed by the trusted CA. This removes token issuance and rotation from the picture entirely, replaced by certificate lifecycle management, and is a strong default for pure internal service mesh traffic (often configured at the infrastructure layer, e.g. via Istio or Linkerd, rather than in application code).
# Example: MCP server behind an Istio sidecar enforcing mTLS
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: enterprise-mcp-server-mtls
spec:
selector:
matchLabels: { app: enterprise-mcp-server }
mtls:
mode: STRICT
Certificate lifecycle is the operational cost that replaces token rotation: certificates have their own expiry, and a service mesh typically automates issuance and renewal well before expiry (short-lived certificates, often valid for hours to days, auto-rotated by the mesh’s sidecar without application involvement). The main failure mode to guard against is a service that caches a TLS connection pool indefinitely and never picks up a renewed certificate until the old one has already expired mid-request, most modern service mesh sidecars handle this transparently, but it is worth confirming explicitly for whatever mesh implementation is in use rather than assuming.
Layering Mechanisms: Why “Pick One” Is the Wrong Frame
It is a mistake to think of API keys, mTLS, and OAuth scopes as mutually exclusive choices where selecting one means forgoing the others’ benefits. In practice, the strongest enterprise deployments layer them: mTLS (or a network-level control like an IP allowlist) establishes which service is calling at the connection level, while OAuth scopes or a role mapping (Lesson 12) determine what that service is allowed to do once connected. A service can authenticate perfectly validly via mTLS and still be authorized for only a narrow slice of tools, the two mechanisms answer genuinely different questions and are not redundant with each other.
flowchart TD
Start{"Who is calling\nthe MCP server?"}
Start -->|"End user via an agent\n(needs delegated, revocable access)"| OAuth["OAuth 2.1\n(Lesson 10)"]
Start -->|"Internal service,\nsame trust domain, mesh-managed"| MTLS["mTLS\n(transport-layer identity)"]
Start -->|"Internal service,\nsimple, no service mesh"| APIKey["API Key\n(hashed, rotated)"]
MTLS --> Layer["+ Scopes/roles (Lesson 12)\nfor per-tool authorization"]
APIKey --> Layer
OAuth --> Layer
style Start fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style OAuth fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style MTLS fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style APIKey fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style Layer fill:#EEF0F7,stroke:#6366F1,color:#0F172A
Whichever authentication mechanism establishes identity, the next lesson builds directly on top of it, extracting scopes or roles from that identity to enforce fine-grained, per-tool authorization.