---
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-29"
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**

## 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, 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` — `initialize — no Authorization header`
6. **MCP server → Your agent** — `200 OK` — `initialize result — 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 — each caller is configured by hand.

## 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 itself carries no credential — the server, or the proxy terminating TLS in front of it, knows the caller from the certificate the connection was established with. 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 from 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
- **MCP spec** — Transport-layer authentication, outside the spec's OAuth profile — which the spec 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

## 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 ClientSession
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 client, not a factory.
    async with httpx2.AsyncClient(verify=ctx) as http_client:
        async with streamable_http_client(
            "https://mcp.example.com/mcp", http_client=http_client
        ) as (read_stream, write_stream):
            async with ClientSession(read_stream, write_stream) as session:
                await session.initialize()
                print(await session.list_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 cannot present a certificate you supply, so mTLS is the wrong answer — check this before anything else.
- The call is made on behalf of a named user. A certificate identifies a workload, and per-user identity needs the spec'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 implementations using an HTTP-based transport 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 exactly that, and neither one's standards list mentions TLS client authentication — the `2026-07-28` list reaches OAuth 2.1, RFC 9728, RFC 8707, RFC 6750, RFC 8414, RFC 7591, RFC 9207 and Client ID Metadata Documents and stops there — which is why no client can discover the requirement.

## What lands on you next

A certificate answers one question — which workload opened this connection — and it answers it only for workloads you run. What arrives next is about people, tools, and what the server does with the identity.

- **Six analysts need two of these tools in Claude. They cannot install a certificate.** — `mcp-oauth-inbound` — The inbound method is a policy on the route rather than server code, so the same server can accept an OAuth sign-in against the identity provider you already run. Exactly one such policy per project.
- **The certificate proves it is our agent. Which tools may our agent call?** — `mcp-capability-filter-inbound` — A static per-route allow-list of tools, prompts and resources. A call to a hidden tool is refused with `MethodNotFound` before it reaches your server, and a read-only view of the same upstream is a second route.
- **Our TLS log has a subject and a timestamp. Which tool actually ran?** — `capability_invocation` — Each tool call emits an event carrying the caller, the capability, the upstream, the outcome and the latency, rather than one connection-level line in an access log.
- **The API behind the server needs its own credential. Do the agents hold that too?** — `mcp-token-exchange-inbound` — No. What the caller presented is stripped and an independent upstream credential is minted per user or per gateway; the spec forbids forwarding an inbound token 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. 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, Anthropic-held client credentials, 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 plainly 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 the leaf certificate's SAN dnsName is mtls.prod.connectors.openai.com. That proves the caller is OpenAI's infrastructure, not your user, and you cannot 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 discusses these variables in terms of the connection to the API provider and never mentions MCP, and the documented fields for an HTTP MCP server entry are url, headers, headersHelper, timeout and alwaysLoad. 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: there is no way to advertise that a server expects a certificate, so each caller is configured directly.

**Which specification revision applies here?**

2026-07-28, the current protocol version. For mTLS it makes no difference. Neither it nor 2025-11-25 describes client certificates, both make authorization OPTIONAL, and both say HTTP transports SHOULD rather than MUST follow the OAuth profile.

**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 spec's OAuth flow behind it. RFC 8705 goes further and binds an access token to the client's certificate, but the MCP authorization spec's standards list does not 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 spec'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 is 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)
