MCP Server Authentication with Mutual TLS
How to implement mutual TLS for MCP server connections — certificate provisioning, client certificate rotation, and why mTLS is the right way to eliminate shared secrets between agents and tool servers.
When an AI agent calls a tool over the Model Context Protocol (MCP), two security questions need answers simultaneously: is this really the server the agent expects, and is this really the agent the server should be serving? Most MCP deployments answer only the first question — TLS with a server certificate — and rely on a shared API key or bearer token for the second. Mutual TLS (mTLS) answers both questions at the transport layer, without any secret that can leak through logs, environment variables, or a compromised config file.
This post walks through what mTLS is, why it matters for agentic workloads, and how to wire it up end-to-end for an MCP server.
Why shared secrets are the wrong primitive for agent-to-server auth
API keys feel simple, but they carry two structural problems that compound in agentic systems.
They travel in every request. An API key appears in headers, in logs, in crash dumps, and in any memory snapshot your agent runtime saves. The blast radius of a single compromised agent is every server that trusts that key.
They can't be scoped to a single logical agent. A bearer token proves possession, not identity. If you have ten agents running in parallel, you either share one key (now compromise = total exposure) or manage ten keys (operational overhead that scales with fleet size).
mTLS solves both. A client certificate is never transmitted as a credential — it is used to sign the TLS handshake itself. The private key never leaves the agent's secure storage. And each agent can carry its own leaf certificate, signed by a common CA, so the server can cryptographically distinguish agent-A from agent-B at the connection level.
The mTLS handshake in 60 seconds
Standard TLS has the client verify the server's certificate. Mutual TLS adds the reverse: the server requests a certificate from the client and verifies it against a trusted CA.
Client Server
| |
|---ClientHello---------------->|
|<--ServerHello, ServerCert-----|
|<--CertificateRequest----------| ← added in mTLS
|---ClientCert, ClientKeyEx---->| ← added in mTLS
|---CertificateVerify---------->| ← added in mTLS
|<--Finished--------------------|
|---Finished------------------->|
| |
| (encrypted application data)|
Everything the application layer sends travels over this mutually-authenticated channel. There is no separate auth step, no token in a header, and no session cookie to steal.
Certificate provisioning
You need three things: a CA (or an intermediate under a root CA), a server certificate, and one client certificate per agent identity.
For development and small deployments, a self-signed CA is fine:
# Create the CA key and certificate
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout ca.key -out ca.crt -days 3650 -nodes \
-subj "/CN=MCP Internal CA"
# Create server cert
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout server.key -out server.csr -nodes \
-subj "/CN=mcp.internal"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt -days 365 \
-extfile <(printf "subjectAltName=DNS:mcp.internal,IP:127.0.0.1")
# Create a client cert for agent-1
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout agent1.key -out agent1.csr -nodes \
-subj "/CN=agent-1/O=bitatlas-agents"
openssl x509 -req -in agent1.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out agent1.crt -days 90
For production, replace the self-signed CA with CFSSL, step-ca, or HashiCorp Vault PKI secrets engine — all of which can issue and automatically renew short-lived certificates.
Configuring the MCP server
Most MCP server frameworks expose a hook for the underlying HTTP/gRPC listener. Here is an example using Node.js with the @modelcontextprotocol/sdk package:
import { createServer } from "https";
import { readFileSync } from "fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
const tlsOptions = {
key: readFileSync("server.key"),
cert: readFileSync("server.crt"),
ca: readFileSync("ca.crt"),
requestCert: true, // ask clients for a certificate
rejectUnauthorized: true, // reject if the cert is not signed by our CA
};
const httpServer = createServer(tlsOptions, (req, res) => {
// The TLS handshake already verified the client cert.
// For additional authz, inspect the peer certificate here:
const peer = (req.socket as any).getPeerCertificate();
const agentId = peer?.subject?.CN ?? "unknown";
const transport = new SSEServerTransport("/messages", res);
mcpServer.connect(transport);
});
httpServer.listen(8443);
The rejectUnauthorized: true flag is the key line: any connection from a client whose certificate is not signed by ca.crt gets terminated at the TLS layer, before a single byte of application data is processed.
Configuring the MCP client (your agent)
On the agent side, pass the client key and certificate when creating the HTTP client:
import https from "https";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const agent = new https.Agent({
ca: readFileSync("ca.crt"), // trust this CA for server cert verification
cert: readFileSync("agent1.crt"), // our client certificate
key: readFileSync("agent1.key"), // private key — never leaves this process
});
const transport = new SSEClientTransport(
new URL("https://mcp.internal:8443/sse"),
{ fetchOptions: { dispatcher: agent } } // Node 18+: use undici dispatcher
);
const client = new Client({ name: "agent-1", version: "1.0.0" }, {});
await client.connect(transport);
The private key is loaded at startup and used only to sign the TLS handshake. It is never serialized or transmitted.
Client certificate rotation
Certificates expire. Short-lived certs (30–90 days) are safer than long-lived ones — a stolen key stops being useful quickly. The rotation workflow is:
- Provision the new certificate before the old one expires (CFSSL and step-ca both support automated renewal).
- Hot-reload in the agent: listen for a SIGHUP or a file-system watch event on the cert file, then rebuild the TLS agent and reconnect.
- Revoke the old certificate: add it to a CRL or publish an OCSP response. Your MCP server should check revocation for long-lived connections.
A simple Node.js hot-reload pattern:
import { watch } from "fs";
let httpsAgent = buildAgent();
watch("agent1.crt", () => {
console.log("cert changed, rotating TLS agent");
httpsAgent = buildAgent();
// reconnect any active MCP sessions
});
What mTLS does not solve
mTLS authenticates who is connecting at the transport layer. It does not:
- Enforce what an authenticated agent is allowed to do (that is authorization, handled in your server's tool-call handlers).
- Protect against a compromised agent that holds a valid certificate — certificate revocation via CRL/OCSP is your backstop there.
- Replace end-to-end payload encryption if the server proxies traffic through an intermediate — in that case, consider payload-level encryption on top of mTLS.
Summary
Mutual TLS replaces the shared-secret model with a cryptographic identity model. Each agent gets its own leaf certificate, its private key never travels over the wire, and the MCP server can distinguish agents before processing a single tool call. The setup cost is a one-time CA provisioning step and a few lines of TLS configuration; the operational benefit is a hardened trust boundary that holds even when environment variables leak or logs are exfiltrated.
For production workloads, pair mTLS with a short certificate lifetime (use step-ca or Vault PKI) and automated renewal, and you get an auth layer that shrinks its own blast radius over time.