---
title: "mTLS (mutual TLS) authentication for MCP servers"
description: "How an agent presents a TLS client certificate to an MCP server, with Go, Python, and TypeScript code — and why no chat client can present one you own."
canonicalUrl: "https://zuplo.com/learn/mcp/authentication/mtls"
pageType: "mcp-auth-guide"
method: "mtls"
lastVerified: "2026-07-31"
specVersion: "2026-07-28"
---

# mTLS (mutual TLS) authentication for MCP servers

> mTLS moves the credential out of the request and into the connection: the agent presents an X.509 certificate during the TLS handshake, so no header carries a secret.

**mTLS · Any platform**

## How it works

The certificate goes on the HTTP client the transport uses, never in the MCP payload: a `tls.Config` on an `http.Client` in Go, an `ssl.SSLContext` passed as `verify` in Python, an `undici` `Agent` behind a custom `fetch` in TypeScript. The request carries no credential — the server, or the proxy terminating TLS in front of it, knows the caller from the certificate that established the connection. That is also the limit: no chat or desktop MCP client config exposes a certificate field, so mTLS reaches servers from agents you write and nothing else.

_MCP specification 2026-07-28._

### At a glance

- **Best for** — Workload-to-workload calls where both ends are yours and a CA already issues certificates
- **Specification** — Transport-layer authentication, outside the specification's OAuth profile — which the specification makes OPTIONAL
- **Works with** — Your own agents only; no chat or desktop client config exposes a certificate field
- **Effort** — An afternoon in code, longer if the PKI does not exist yet

### The exchange

The agent opens a TLS connection, the server asks for a certificate, and the agent presents one signed by a CA the server trusts. The server verifies the chain and reads the subject. Only then does the first MCP message go out, and it carries no credential of its own: no 401, no discovery document, and no browser step.

**Participants:** Your agent (holds cert and key), MCP server (or the proxy in front of it)

1. **Your agent → MCP server** — `TLS ClientHello` — `SNI: mcp.example.com`
2. **MCP server → Your agent** — `CertificateRequest` — `acceptable CAs` _(response)_
3. **Your agent → MCP server** — `Certificate, CertificateVerify` — `CN=orders-agent, issued by your CA` _(carries the credential)_
4. **MCP server** (internal step) — Verify the chain, then read the subject
5. **Your agent → MCP server** — `POST /mcp` — `tools/list — no Authorization header`
6. **MCP server → Your agent** — `200 OK` — `tools listed` _(response)_

Notes:

- **Steps 1–4** — The exchange happens once per connection, before any MCP message exists. A server that never sends CertificateRequest never sees a certificate, however well the client is configured.
- **Steps 5–6** — Every message on this connection is already authenticated, so a request copied out of a log carries nothing to replay. The cost is that nothing in MCP advertises the requirement, so you configure each caller by hand.

## Connect your agent

Each sample connects to the server, sends the credential, and exposes the tools to the model.

### Go

#### MCP Go SDK

```go title="main.go"
import (
    "context"
    "crypto/tls"
    "net/http"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

// StreamableClientTransport takes an *http.Client, so the certificate
// goes on that client's TLS config. Nothing MCP-specific is involved.
func connect(ctx context.Context) (*mcp.ClientSession, error) {
    pair, err := tls.LoadX509KeyPair("client.pem", "client.key")
    if err != nil {
        return nil, err
    }

    client := mcp.NewClient(
        &mcp.Implementation{Name: "my-agent", Version: "1.0.0"}, nil)

    return client.Connect(ctx, &mcp.StreamableClientTransport{
        Endpoint: "https://mcp.example.com/mcp",
        HTTPClient: &http.Client{Transport: &http.Transport{
            // Set RootCAs too if your CA is not in the system store.
            TLSClientConfig: &tls.Config{
                Certificates: []tls.Certificate{pair},
            },
        }},
    }, nil)
}
```

### Python

#### MCP Python SDK

```python title="agent.py"
import asyncio, ssl
import httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client

# httpx2 takes an ssl.SSLContext as `verify`; the client certificate
# is loaded into that context, not passed as a separate argument.
ctx = ssl.create_default_context(cafile="ca.pem")
ctx.load_cert_chain(certfile="client.pem", keyfile="client.key")

async def main():
    # SDK 2.0 takes a pre-built httpx2 client, not a factory. There is
    # no initialize() to await: the handshake is gone, and the first
    # call on the wire is already an ordinary request.
    async with httpx2.AsyncClient(verify=ctx) as http:
        async with Client(streamable_http_client(
            "https://mcp.example.com/mcp", http_client=http
        )) as client:
            print([t.name for t in (await client.list_tools()).tools])

asyncio.run(main())
```

#### FastMCP

```python title="agent.py"
import ssl
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

ctx = ssl.create_default_context(cafile="ca.pem")
ctx.load_cert_chain(certfile="client.pem", keyfile="client.key")

# `verify` accepts a bool, a CA bundle path, or an SSLContext. The
# context is the only form that can also carry a client certificate.
client = Client(StreamableHttpTransport(
    url="https://mcp.example.com/mcp",
    verify=ctx,
))

async def main():
    async with client:
        print(await client.list_tools())
```

### TypeScript

#### MCP TypeScript SDK

```typescript title="agent.ts"
import { readFileSync } from "node:fs";
import { Agent, fetch as undiciFetch } from "undici";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

// undici's `connect` accepts every tls.connect() option. Import fetch
// from undici too — `dispatcher` is undici's own fetch option, and
// Node's global fetch has no way to attach an agent.
const dispatcher = new Agent({
  connect: {
    cert: readFileSync("client.pem"),
    key: readFileSync("client.key"),
  },
});

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  { fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }) },
);

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
```

## When to use something else

**Use this when:**

- Both ends are workloads you run and a CA already issues their certificates, so there is no shared secret in an environment variable.
- A network or compliance rule requires a client certificate on every connection, and TLS terminates in a proxy or mesh you control.
- You want the credential bound to the connection rather than to a header, so nothing replayable appears in a request or a log.

**Use something else when:**

- A person has to reach this server from a chat client. Claude, ChatGPT, Cursor, and VS Code can't present a certificate you supply, so mTLS is the wrong answer — check this before anything else.
- The agent calls on behalf of a named user. A certificate identifies a workload, and per-user identity needs the specification's OAuth flow.
- Nobody owns rotation. Expiry fails the handshake, so every agent stops at once and no MCP error says why.

> Authorization is OPTIONAL, and HTTP-based transports SHOULD rather than MUST conform to the specification's OAuth profile, so a client certificate sits outside the specification rather than in conflict with it. Both live revisions say that, and neither one's standards list mentions TLS client authentication. The `2026-07-28` list runs to ten entries — OAuth 2.1, RFC 9728, RFC 8707, RFC 6750, RFC 8414, RFC 7591, RFC 9207, Client ID Metadata Documents, OpenID Connect Discovery 1.0, and OpenID Connect Dynamic Client Registration 1.0 — and none of them is this, which is why no client can discover the requirement.

## Next steps to production

A certificate says which workload opened this connection, and only for workloads you run; what arrives next is about people, tools, and what the server does with that identity.

1. **Add the second door** — mTLS stays exactly as it is for the workloads you run. The gateway is a second route, for the callers that cannot present a certificate at all — every chat and desktop client.

   ```
   "url": "https://api.example.com/mcp"
   ```
2. **Add the policies** — A list on the route, and the options that go with it. The gateway authenticates the caller its own way, then presents the client certificate your server already expects — gateway-to-origin mTLS, an Enterprise add-on.

   ```
   "inbound": [
     "mcp-oauth-inbound",
     "mcp-capability-filter-inbound",
     "mcp-token-exchange-inbound"
   ]
   ```
3. **Deploy** — Your PKI, your CA, and your certificate-bearing workloads are untouched. The new route runs beside them.

   ```
   zuplo deploy
   ```

- **Six analysts need two of these tools in Claude. They cannot install a certificate.** — `mcp-oauth-inbound` — They sign in instead. The same server gets an OAuth door — a policy on the route, against the identity provider you already run — while your certificate-bearing workloads keep theirs. One MCP OAuth policy per project.
- **The certificate proves it is our agent. Which tools can our agent call?** — `mcp-capability-filter-inbound` — Exactly the ones you list — a narrower answer than the certificate's, which only ever said "our agent may connect". Anything else is refused with `MethodNotFound` before it reaches your server.
- **Our TLS log has a subject and a timestamp. Which tool actually ran?** — `capability_invocation` — The event stream says: every tool call carries the caller, the tool, the upstream, the outcome, and the latency — per call, where a TLS log stops at the connection.
- **The API behind the server needs its own credential. Do the agents hold that too?** — `mcp-token-exchange-inbound` — No — the gateway holds it and presents it per call; the agents never see it. Forwarding what a caller sent is the one thing the specification forbids with a MUST NOT.

All of these attach to one MCP route's `policies.inbound` — the same policy engine on the way in and on the way out. Your MCP server keeps the code and the authentication it has today. See [Zuplo MCP Gateway](/mcp-gateway).

## FAQ

**Can Claude, ChatGPT, or Cursor connect to a server that requires a client certificate?**

No. Claude's connector documentation lists six supported authentication types — OAuth with DCR, OAuth with a Client ID Metadata Document, OAuth with an Anthropic-held client ID and secret (a pre-registered client, not the `client_credentials` grant, which Claude does not support), custom connection, static headers in beta, and none — and a certificate is not among them. Cursor documents url, headers, and auth for a remote server; VS Code documents type, url, headers, and oauth. OpenAI states that ChatGPT cannot present customer-provided mTLS certificates.

**Doesn't ChatGPT already use mTLS with MCP servers?**

Yes, in the other direction. OpenAI documents that ChatGPT presents an OpenAI-managed client certificate when establishing TLS connections to MCP servers, and tells you to verify that the leaf certificate's SAN dnsName is mtls.prod.connectors.openai.com. That proves the caller is OpenAI's infrastructure rather than your user, and you can't substitute your own certificate. OAuth 2.1 still authenticates the person.

**Claude Code documents CLAUDE_CODE_CLIENT_CERT. Does that cover MCP servers?**

Treat it as unverified for MCP. Claude Code documents CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY as enterprise network configuration, read at startup and re-read when settings change, and /status shows an mTLS client cert row once the files load. That page frames them as the connection to the API provider and never mentions MCP, and the documented fields for an HTTP MCP server entry — type, url, headers, headersHelper, timeout, alwaysLoad — include no certificate. Test it against your own server before designing around it.

**Does mTLS violate the MCP specification?**

No. Authorization is OPTIONAL, and HTTP transports SHOULD rather than MUST conform to the OAuth profile, so transport-layer client authentication is out of scope rather than in conflict. What you give up is discovery: nothing advertises that a server expects a certificate, so you configure each caller directly.

**Which specification revision applies here?**

2026-07-28, the current protocol version, and for mTLS it makes no difference: neither it nor 2025-11-25 describes client certificates, and both make authorization OPTIONAL.

**Is client certificate authentication the same thing as mTLS?**

Yes. mTLS, mutual TLS, two-way SSL, and X.509 client certificate authentication all name the same handshake. It never touches the MCP protocol, so support depends only on whether your SDK lets you supply the HTTP client — which the Go, Python, and TypeScript SDKs all do.

**Can mTLS and OAuth be combined?**

They sit at different layers, so yes: terminate mTLS at the edge and run the specification's OAuth flow behind it. RFC 8705 goes further and binds an access token to the client's certificate, but the MCP authorization specification's standards list doesn't include RFC 8705, so nothing in MCP asks a client to produce a certificate-bound token.

**What happens when a certificate expires?**

The handshake fails before any MCP message exists. The specification's error table covers 401, 403, and 400, and a TLS failure becomes none of them, so agents report a transport error and the reason lands in your server's TLS log rather than in the protocol. Short-lived certificates need automated renewal on both ends.

## Keep reading

- **API key** — [API key authentication for MCP servers](/learn/mcp/authentication/api-key) — Service-to-service calls where you control both the agent and the MCP server
- **MCP Authentication** — [every authentication method](/learn/mcp/authentication)
