MCP Transport Security: Closing the Authentication Gap in SSE and HTTP Transports

MCP Transport Security: Closing the Authentication Gap in SSE and HTTP Transports

The Problem

MCP defines three transport types. Only one of them is safe by default.

stdio makes the MCP server a child process communicating over standard input/output. The parent process owns the child process. No socket is opened, no port is bound. The only processes that can exchange messages are the ones sharing the pipe. Isolation is enforced by the operating system’s process ownership model.

SSE (Server-Sent Events) makes the MCP server an HTTP server. The client connects via HTTP GET to a /sse endpoint and receives a persistent event stream. It sends tool calls via HTTP POST to a separate /message endpoint, including a session ID returned in the initial SSE headers. This is a network service, and it behaves like every other network service: it accepts connections from anyone who can reach it.

Streamable HTTP (the 2025 revision’s successor to SSE) unifies these into a single endpoint that speaks both request-response and streaming over HTTP, but inherits the same authentication gap.

The MCP specification, as of the March 2025 revision, does not mandate authentication for SSE or HTTP transports. The Python reference implementation (fastmcp) and the TypeScript SDK (@modelcontextprotocol/sdk) ship with no authentication middleware. A developer who runs uvicorn mcp_server:app --host 0.0.0.0 --port 3000 has published an unauthenticated tool execution endpoint to their network.

This pattern has a documented history. Elasticsearch shipped with no authentication before 6.0 and was deployed at scale on cloud instances with public IPs, leaking hundreds of millions of records. Redis shipped with no authentication for years and was routinely exposed on publicly routable addresses. The Kubernetes dashboard was deployed in-cluster without authentication because cluster networking was assumed to be trusted. In every case, the developers’ implicit assumption — “only trusted processes can reach this” — turned out to be wrong at scale.

MCP’s exposure profile is worse than Redis or Elasticsearch in one specific dimension: what you can do with access. Redis access lets you read and write cache data. MCP access lets you execute tools — run shell commands, read files, make API calls, execute code — with the full permissions of the MCP server’s service account. An unauthenticated MCP server is not a data exposure; it is arbitrary code execution in the context of whatever identity the server runs as.

How SSE Transport Works

Understanding the attack surface requires understanding the wire protocol.

The MCP SSE transport has two endpoints. The client opens a long-lived HTTP GET connection to /sse:

GET /sse HTTP/1.1
Host: mcp-server.internal:3000
Accept: text/event-stream

The server responds with Content-Type: text/event-stream and holds the connection open. The first event the server sends is an endpoint event that gives the client the URL it should use for sending messages:

event: endpoint
data: /message?sessionId=a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5

The session ID is a random token the server generates. Subsequent tool calls go to that /message URL as HTTP POST requests:

POST /message?sessionId=a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5 HTTP/1.1
Content-Type: application/json

{"jsonrpc":"2.0","method":"tools/call","params":{"name":"bash","arguments":{"command":"id"}},"id":1}

The response arrives back over the SSE stream, not in the POST response body. The POST returns 202 Accepted; the actual result comes as a message event on the open SSE connection.

Two things follow from this design. First, the session ID is the only thing preventing an attacker from posting tool calls to an existing SSE connection they did not initiate — and session IDs are not credentials. They are random tokens transmitted in plaintext over HTTP, which means any network observer between client and server can capture them and hijack the session. Second, without authentication on the GET endpoint, there is nothing preventing an attacker from opening their own SSE connection and getting their own session ID. Authentication is not optional — it is the entire difference between a tool execution service and a public tool execution service.

Threat Model

Lateral Movement to Unauthenticated MCP Server

An attacker who achieves code execution in a container in the same Kubernetes namespace as an MCP server — via a deserialization bug in a web application, a supply-chain compromise in a dependency, or a stolen service account token — can reach the MCP server directly. Kubernetes network policies are not enabled by default. Pods in the same namespace can reach each other on any port.

# From a compromised container:
curl -N http://mcp-server.svc.cluster.local:3000/sse
# Server returns session ID in endpoint event
# Post tool calls using that session ID:
curl -X POST "http://mcp-server.svc.cluster.local:3000/message?sessionId=<id>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"kubectl","arguments":{"args":["get","secrets","--all-namespaces"]}},"id":1}'

No credential theft. No privilege escalation. The MCP server’s service account has the permissions; the attacker just needs to reach the socket.

VPN User Directly Calling MCP Kubectl Tools

An MCP server that provides kubectl access for AI agents is intended to be called by those agents, through approval flows, with audit logging. A developer on VPN who discovers the MCP server’s internal DNS name can bypass all of that:

# Developer on VPN, debugging an issue, finds MCP server in service discovery
curl -N https://mcp-kubectl.internal:3000/sse
# Gets a session ID, starts calling kubectl tools directly
curl -X POST "https://mcp-kubectl.internal:3000/message?sessionId=<id>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"kubectl_delete","arguments":{"resource":"pod","name":"production-db-0","namespace":"prod"}},"id":1}'

The MCP server executes the command because nothing in its authentication model distinguishes “AI agent with human approval” from “developer with curl.” The audit trail shows a tool call with no user attribution.

DNS Rebinding Attack on Localhost MCP Server

A developer runs an MCP server on localhost:3000 for local development. They visit a malicious website. The website’s JavaScript initiates a DNS rebinding attack: the attacker’s domain initially resolves to the attacker’s server IP (to load the malicious JavaScript), then the DNS record’s TTL expires and is updated to resolve to 127.0.0.1. The browser’s same-origin policy now permits the JavaScript to make requests to localhost:3000 because the hostname resolves to localhost.

The JavaScript opens an SSE connection, retrieves a session ID, and posts tool calls — reading ~/.ssh/id_rsa, ~/.aws/credentials, or executing arbitrary commands via a shell tool — from within the developer’s browser. The MCP server sees requests from localhost and accepts them.

This attack is not theoretical. DNS rebinding toolkits are publicly available. The mitigations are: validate the Host header on incoming requests (reject anything that is not localhost or 127.0.0.1), bind to 127.0.0.1 rather than all interfaces, and require authentication that a browser cross-origin JavaScript context cannot supply (mTLS client certificates, tokens not stored in JavaScript-accessible cookie jars).

SSRF via Web Application

An internal web application with a server-side request forgery vulnerability can be weaponised to reach an MCP server on the internal network. The attacker provides a URL pointing to the MCP server’s /message endpoint. The web application’s backend makes the request on behalf of the attacker, with the web application’s network connectivity. If the MCP server trusts all internal-network traffic, SSRF becomes MCP tool execution.

Hardening Configuration

The two complementary patterns are mTLS for service-to-service authentication and OAuth 2.0 for user-delegated agent authorisation. They address different principals: mTLS identifies which service is connecting; OAuth 2.0 identifies which user authorised the agent. In a production deployment, use both.

1. Bind the MCP Server to Localhost Only

This is the minimum baseline and the most frequently skipped step. An MCP server bound to 0.0.0.0 is reachable from any network interface. One bound to 127.0.0.1 is not directly reachable from the network — traffic must come through a reverse proxy that you control.

# mcp_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("kubectl-tools")

@mcp.tool()
async def kubectl_get(resource: str, namespace: str = "default") -> str:
    # ... implementation
    pass

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        mcp.get_app(),
        host="127.0.0.1",  # Bind to loopback only — never 0.0.0.0
        port=3000,
        # TLS for the loopback listener is redundant (traffic doesn't leave the host)
        # but keeps the server behind the reverse proxy's TLS termination
    )

This does not authenticate connections — it reduces the attack surface so that only processes on the same host can reach the MCP server directly. Actual authentication lives in the reverse proxy in front of it.

2. Nginx Reverse Proxy with mTLS

The reverse proxy terminates TLS and enforces client certificate validation before forwarding any traffic to the MCP server. The MCP server itself has no authentication logic — it trusts that anything reaching 127.0.0.1:3000 has already been authenticated by nginx.

# /etc/nginx/conf.d/mcp-server.conf
upstream mcp_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name mcp-server.internal;

    # Server TLS certificate
    ssl_certificate     /etc/ssl/mcp/server.crt;
    ssl_certificate_key /etc/ssl/mcp/server.key;
    ssl_protocols       TLSv1.3;
    ssl_ciphers         TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;

    # mTLS: require a client certificate signed by the MCP CA
    ssl_client_certificate /etc/ssl/mcp/ca.crt;
    ssl_verify_client      on;
    ssl_verify_depth       2;

    # Reject clients whose certificate CN is not in the authorised set
    # This is coarse-grained access control — see OAuth scopes for fine-grained
    if ($ssl_client_s_dn !~ "CN=(claude-agent|gpt-agent|sre-agent|ci-agent)") {
        return 403 "Client certificate not authorised for MCP access";
    }

    # SSE endpoint — critical: disable proxy buffering
    # nginx buffers responses by default; buffering breaks SSE because events
    # are held in the buffer rather than flushed to the client immediately
    location /sse {
        proxy_pass         http://mcp_backend;
        proxy_http_version 1.1;

        # Pass the verified client identity to the MCP server
        proxy_set_header   X-Client-CN    $ssl_client_s_dn;
        proxy_set_header   X-Forwarded-For $remote_addr;
        proxy_set_header   Host            $host;

        # SSE-specific proxy settings
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;   # Hold the SSE connection for up to 1 hour
        proxy_send_timeout 3600s;

        # Prevent nginx from closing the connection on upstream silence
        proxy_connect_timeout 60s;
    }

    # Message endpoint — standard reverse proxy
    location /message {
        proxy_pass         http://mcp_backend;
        proxy_http_version 1.1;
        proxy_set_header   X-Client-CN     $ssl_client_s_dn;
        proxy_set_header   X-Forwarded-For $remote_addr;
        proxy_set_header   Host            $host;
        proxy_read_timeout 30s;
    }

    # Block all other paths
    location / {
        return 404;
    }
}

# Redirect HTTP to HTTPS — never serve MCP over plaintext
server {
    listen 80;
    server_name mcp-server.internal;
    return 301 https://$host$request_uri;
}

The proxy_buffering off directive on the /sse location is not optional. Nginx’s default behaviour is to buffer upstream responses and flush them in chunks. An SSE stream buffered this way delivers events in batches when the buffer fills rather than immediately — tools that depend on streaming output will appear to hang, and clients that implement SSE heartbeat timeouts will disconnect. Every nginx configuration for SSE must disable buffering.

3. Generate the mTLS Certificate Hierarchy

Each agent identity gets its own client certificate. Revoking an agent’s access means revoking its certificate — no credential rotation on the MCP server required.

# Step 1: Create the MCP internal CA
# This CA signs client certificates for agent identities
# Keep the CA key offline or in a secrets manager — it is the trust anchor
openssl req -x509 \
  -newkey rsa:4096 \
  -keyout /etc/ssl/mcp/ca.key \
  -out    /etc/ssl/mcp/ca.crt \
  -days   730 \
  -nodes \
  -subj   "/CN=MCP Internal CA/O=Company/OU=Platform Security"

# Step 2: Create the server certificate for the MCP reverse proxy
openssl req -newkey rsa:2048 \
  -keyout /etc/ssl/mcp/server.key \
  -out    /etc/ssl/mcp/server.csr \
  -nodes \
  -subj   "/CN=mcp-server.internal/O=Company"

openssl x509 -req \
  -in     /etc/ssl/mcp/server.csr \
  -CA     /etc/ssl/mcp/ca.crt \
  -CAkey  /etc/ssl/mcp/ca.key \
  -CAcreateserial \
  -out    /etc/ssl/mcp/server.crt \
  -days   365 \
  -extfile <(printf "subjectAltName=DNS:mcp-server.internal,DNS:localhost")

# Step 3: Generate a client certificate for each agent identity
for AGENT in claude-agent gpt-agent sre-agent ci-agent; do
  openssl req -newkey rsa:2048 \
    -keyout /etc/ssl/mcp/clients/${AGENT}.key \
    -out    /etc/ssl/mcp/clients/${AGENT}.csr \
    -nodes \
    -subj   "/CN=${AGENT}/O=Company/OU=AI-Agents"

  openssl x509 -req \
    -in     /etc/ssl/mcp/clients/${AGENT}.csr \
    -CA     /etc/ssl/mcp/ca.crt \
    -CAkey  /etc/ssl/mcp/ca.key \
    -CAcreateserial \
    -out    /etc/ssl/mcp/clients/${AGENT}.crt \
    -days   365

  # Package as a bundle the agent runtime can load
  cat /etc/ssl/mcp/clients/${AGENT}.crt \
      /etc/ssl/mcp/clients/${AGENT}.key \
    > /etc/ssl/mcp/clients/${AGENT}-bundle.pem

  echo "Generated certificate for ${AGENT}: $(openssl x509 -noout -fingerprint \
    -sha256 -in /etc/ssl/mcp/clients/${AGENT}.crt)"
done

In Kubernetes, automate certificate issuance with cert-manager and a Certificate resource backed by an internal Issuer. Cert-manager handles renewal, stores certificates in Secrets, and can mount them as volumes into agent pods. Manual certificate generation does not scale past a handful of agents.

4. OAuth 2.0 Token Validation Middleware

mTLS authenticates the service identity (which agent runtime is connecting). OAuth 2.0 authenticates the user identity (which human authorised this agent session) and controls which tools that authorisation covers. Both are needed for a complete picture.

The MCP 2025 specification defines an OAuth 2.0 authorisation flow for SSE/HTTP transports: the MCP server acts as a resource server, validates Bearer tokens issued by an authorisation server, and maps token scopes to tool permissions.

# auth_middleware.py
# FastAPI middleware for MCP server OAuth 2.0 token validation
# Implements MCP 2025 OAuth 2.0 resource server requirements

import httpx
import logging
from functools import lru_cache
from typing import Optional

from fastapi import HTTPException, Request, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)

# Scope-to-tool mapping: which OAuth scopes authorise which MCP tools
SCOPE_TOOL_MAP = {
    "mcp:tools:read":      {"list_files", "read_file", "kubectl_get"},
    "mcp:tools:write":     {"write_file", "kubectl_apply", "kubectl_delete"},
    "mcp:tools:exec":      {"bash", "python_exec"},
    "mcp:admin":           None,   # None = unrestricted
}

async def validate_oauth_token(
    request: Request,
    credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> dict:
    """
    Validate an OAuth 2.0 Bearer token via token introspection.
    Returns the token info dict on success; raises HTTPException on failure.

    Expects the authorisation server to support RFC 7662 token introspection.
    """
    if credentials is None:
        raise HTTPException(
            status_code=401,
            detail="Bearer token required",
            headers={"WWW-Authenticate": 'Bearer realm="mcp-server"'},
        )

    token = credentials.credentials

    # Token introspection: ask the authorisation server if this token is valid
    # The MCP server authenticates to the auth server with its own client credentials
    async with httpx.AsyncClient(timeout=5.0) as client:
        try:
            response = await client.post(
                "https://auth.company.com/oauth2/introspect",
                data={"token": token, "token_type_hint": "access_token"},
                auth=(MCP_SERVER_CLIENT_ID, MCP_SERVER_CLIENT_SECRET),
            )
            response.raise_for_status()
        except httpx.TimeoutException:
            logger.error("Token introspection timed out")
            raise HTTPException(status_code=503, detail="Auth service unavailable")
        except httpx.HTTPStatusError as e:
            logger.error("Token introspection failed: %s", e.response.status_code)
            raise HTTPException(status_code=503, detail="Auth service error")

    token_info = response.json()

    # The `active` field is the canonical validity indicator per RFC 7662
    if not token_info.get("active"):
        raise HTTPException(
            status_code=401,
            detail="Token is inactive, expired, or revoked",
            headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
        )

    # Validate audience: token must have been issued for this MCP server
    # Prevents token confusion attacks where a token for a different resource
    # is presented to the MCP server
    aud = token_info.get("aud", [])
    if isinstance(aud, str):
        aud = [aud]
    if MCP_SERVER_RESOURCE_ID not in aud:
        raise HTTPException(
            status_code=403,
            detail=f"Token audience does not include this MCP server",
        )

    # At least one recognised MCP scope must be present
    token_scopes = set(token_info.get("scope", "").split())
    mcp_scopes = token_scopes & set(SCOPE_TOOL_MAP.keys())
    if not mcp_scopes:
        raise HTTPException(
            status_code=403,
            detail="Token does not carry any MCP tool scopes",
            headers={"WWW-Authenticate": 'Bearer error="insufficient_scope"'},
        )

    # Attach decoded token info to the request state for downstream handlers
    request.state.token_info = token_info
    request.state.mcp_scopes = mcp_scopes

    return token_info


def check_tool_authorisation(tool_name: str, token_scopes: set[str]) -> None:
    """
    Verify that the token's scopes authorise calling the named tool.
    Called before executing any tool.
    """
    for scope in token_scopes:
        allowed_tools = SCOPE_TOOL_MAP.get(scope)
        if allowed_tools is None:  # mcp:admin — unrestricted
            return
        if tool_name in allowed_tools:
            return

    raise HTTPException(
        status_code=403,
        detail=f"Tool '{tool_name}' not authorised by token scopes: {token_scopes}",
        headers={"WWW-Authenticate": 'Bearer error="insufficient_scope"'},
    )

Scope validation at the tool level is the part most implementations skip. Accepting any valid Bearer token and then executing any tool is better than no authentication but still wrong: a token issued for mcp:tools:read should not be able to call bash. Map scopes to specific tool names and check before dispatch.

5. Session Binding: Tying SSE Sessions to Authenticated Identities

The MCP SSE protocol’s session ID is generated on the server and returned to the client in the initial endpoint event. Without binding, any party who observes the session ID (via network interception of an unencrypted session, via log injection, or via the SSE stream itself) can post tool calls to that session. With mTLS, the nginx layer ensures that traffic reaching the /message endpoint comes from an authenticated client, and the X-Client-CN header identifies which client. Session binding checks that the client posting to a session is the same client that opened it.

# session_store.py
import secrets
import time
from dataclasses import dataclass, field
from threading import Lock

@dataclass
class MCPSession:
    session_id: str
    client_cn: str          # mTLS client certificate CN (from X-Client-CN header)
    user_id: str            # OAuth token subject (human who authorised this session)
    token_scopes: set[str]  # OAuth scopes at session creation time
    created_at: float = field(default_factory=time.time)
    last_activity: float = field(default_factory=time.time)
    tool_call_count: int = 0

SESSION_STORE: dict[str, MCPSession] = {}
SESSION_LOCK = Lock()
SESSION_TIMEOUT_SECONDS = 3600   # 1 hour of inactivity


def create_session(client_cn: str, user_id: str, token_scopes: set[str]) -> str:
    """
    Create a new MCP session bound to the authenticated client identity.
    The session ID is a 32-byte cryptographically random token.
    """
    session_id = secrets.token_urlsafe(32)
    with SESSION_LOCK:
        SESSION_STORE[session_id] = MCPSession(
            session_id=session_id,
            client_cn=client_cn,
            user_id=user_id,
            token_scopes=token_scopes,
        )
    return session_id


def validate_session(session_id: str, client_cn: str) -> MCPSession:
    """
    Validate a session ID and confirm it belongs to the presenting client.
    Raises AuthError if the session does not exist, has expired, or belongs
    to a different client — the last case indicates a session hijacking attempt.
    """
    with SESSION_LOCK:
        session = SESSION_STORE.get(session_id)

        if session is None:
            raise AuthError("Session not found or already expired")

        # Client binding check: the CN presenting this session ID must match
        # the CN that created it. A mismatch means the session ID was obtained
        # by a party other than the one that opened the SSE connection.
        if session.client_cn != client_cn:
            # Log this as a security event before raising
            logger.warning(
                "Session binding violation: session %s was created by %s "
                "but presented by %s — possible session hijack attempt",
                session_id[:8] + "...",
                session.client_cn,
                client_cn,
            )
            raise AuthError("Session client mismatch — access denied")

        now = time.time()
        if now - session.last_activity > SESSION_TIMEOUT_SECONDS:
            del SESSION_STORE[session_id]
            raise AuthError("Session expired due to inactivity")

        session.last_activity = now
        session.tool_call_count += 1
        return session

6. Kubernetes Network Policy

Network policy restricts which pods can initiate connections to the MCP server at the network layer, independent of application-layer authentication. Both layers are needed: network policy prevents attacker-controlled pods from reaching the MCP server at all; mTLS ensures that even if a pod in the allowed set is compromised, it cannot authenticate without the client certificate that pod is provisioned with.

# network-policy-mcp.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: mcp-server-ingress
  namespace: mcp-tools
spec:
  podSelector:
    matchLabels:
      role: mcp-server
  policyTypes:
    - Ingress
  ingress:
    # Allow inbound connections only from pods labelled as AI agents
    - from:
        - podSelector:
            matchLabels:
              role: ai-agent
        # If agents run in a different namespace, add namespaceSelector
        # - namespaceSelector:
        #     matchLabels:
        #       name: agent-runtime
      ports:
        - port: 443
          protocol: TCP
    # Allow health checks from the cluster's monitoring namespace
    - from:
        - namespaceSelector:
            matchLabels:
              name: monitoring
        - podSelector:
            matchLabels:
              app: prometheus
      ports:
        - port: 9090
          protocol: TCP
  # No egress restrictions defined here — apply separately via egress NetworkPolicy

This policy implements a default-deny posture for the MCP server pod: only pods with role: ai-agent can initiate TCP connections to port 443. A compromised pod in the same namespace that does not have that label is blocked at the network layer before the TLS handshake even begins.

Expected Behaviour

Unauthorised mTLS client: A client that presents no certificate, or a certificate signed by a CA not in nginx’s ssl_client_certificate bundle, receives a TLS handshake failure before any HTTP request is processed. The connection is terminated at the TLS layer. Nginx logs: SSL_do_handshake() failed (SSL: error:... certificate verify failed). The client sees a TLS alert, not an HTTP response.

A client that presents a valid certificate but with a CN not matching the allowed pattern (if ($ssl_client_s_dn !~ "...") { return 403; }) completes the TLS handshake but receives 403 Forbidden on the HTTP request. This is the correct response for the case where the certificate is valid but the identity is not authorised.

Expired OAuth token: The introspection endpoint returns {"active": false}. The MCP server returns 401 Unauthorized with WWW-Authenticate: Bearer error="invalid_token". The client must obtain a new token via the OAuth authorisation server before retrying.

Wrong audience: A token issued for the logging service presented to the MCP server returns 403 Forbidden with "Token audience does not include this MCP server". This surfaces token confusion attacks where credentials obtained from one service are replayed against another.

Session binding violation: A session ID posted from a client CN that did not create the session returns 403 Forbidden. The server logs a WARNING with both CNs. This is an anomalous event that should trigger an alert — in normal operation, the client that opens the SSE connection is the same client that posts to it.

Tool scope violation: A token carrying only mcp:tools:read attempting to call bash returns 403 Forbidden with "Tool 'bash' not authorised by token scopes". The tool is not called. This is the correct enforcement boundary — authentication alone (proving who you are) does not imply authorisation (permission to do a specific thing).

Trade-offs

mTLS certificate management: Issuing, distributing, and rotating client certificates for each agent identity is operational overhead. In Kubernetes, cert-manager with an internal ClusterIssuer eliminates most of this: certificates are issued automatically when the Certificate resource is created, rotated before expiry, and mounted into agent pods as Kubernetes Secrets. Outside Kubernetes, SPIFFE/SPIRE provides workload identity and certificate issuance in a way that is not tied to manual processes. The overhead is real but manageable with the right tooling.

OAuth 2.0 dependency: Validating tokens via introspection adds a synchronous round-trip to an external service on every request. If the authorisation server is unavailable, the MCP server cannot validate tokens. Mitigations: use JWT Bearer tokens with local signature verification (removing the introspection round-trip at the cost of delayed revocation), cache introspection results with a short TTL (30–60 seconds), and configure the MCP server to fail closed (deny all requests if the auth server is unreachable) rather than fail open. For local development, a minimal OAuth server (Keycloak in a container, or a stub server that issues tokens for test identities) avoids the dependency on production infrastructure.

localhost binding: Binding to 127.0.0.1 does not help when the MCP server needs to be accessible across hosts. Use the reverse proxy pattern: MCP server on localhost, nginx with mTLS on the network-facing port. This adds one hop but keeps authentication logic in a well-understood component (nginx) rather than in the MCP server itself, which may not have robust auth primitives.

TLS version and cipher selection: TLSv1.3 only is appropriate for internal services where you control all clients. TLSv1.2 compatibility may be required if older client libraries are in use. Always prefer TLSv1.3 when you can enforce it — the protocol removes negotiated cipher suites for AEAD-only ciphers, eliminating the negotiation downgrade attacks that were possible with TLSv1.2.

Failure Modes

Deploying on 0.0.0.0 without authentication because “it’s internal”: This is the foundational error. Internal network access does not imply authorisation. Any process that achieves code execution within the internal network — a compromised container, a supply-chain attack in a dependency, a developer’s laptop on VPN — can reach a 0.0.0.0-bound service. The assumption that internal network position is equivalent to trust is what made Elasticsearch, Redis, and the Kubernetes dashboard exposures possible. Bind to 127.0.0.1 and put authentication on the proxy.

HTTP instead of HTTPS for SSE on internal networks: The SSE session ID is transmitted from the server to the client in the endpoint event. Over HTTP, any network observer — another container on the same Kubernetes node, a misconfigured span port, a compromised pod with network sniffing capability — can capture the session ID and post tool calls to that session. TLS is not optional on a protocol that transmits session tokens.

Not validating OAuth scopes: Accepting any valid Bearer token and executing any requested tool conflates authentication (“this token is valid”) with authorisation (“this token can do this specific thing”). An agent whose session was authorised for read-only tool access should not be able to call bash or kubectl_delete because those tools are not in scope. Scope validation at the tool dispatch layer is the enforcement boundary.

Session IDs without client binding: Without binding the session ID to the authenticated client identity, session ID theft enables impersonation. An attacker who captures a session ID (via log injection, via an unencrypted session, via an application that logs request URLs) can post tool calls to that session with their own client certificate. The session binding check — verifying that the CN posting to the session matches the CN that created it — converts session ID theft into a detectable event rather than a silent compromise.

Skipping the nginx proxy_buffering off directive: This does not cause a security failure, but it causes an operational one that leads to workarounds that do cause security failures. When developers find that SSE is not working through their nginx proxy (because events are buffered rather than streamed), they commonly respond by either removing the proxy (exposing the MCP server directly on the network) or switching to HTTP polling (losing the real-time streaming model). The correct response is proxy_buffering off in the SSE location block. Document it in your nginx configuration with a comment explaining why — the next person to review the config will wonder if it is a mistake.

Not revoking certificates when agent identities change: An agent whose service account is decommissioned but whose client certificate is not revoked remains capable of authenticating to the MCP server until the certificate expires. Maintain a CRL (Certificate Revocation List) or use OCSP, and configure nginx to check it: ssl_crl /etc/ssl/mcp/crl.pem;. In cert-manager deployments, certificate deletion revokes the certificate and removes it from the Secret — but the nginx CRL or OCSP configuration must be in place for revocation to have any effect at runtime.