Topic: mcp server mutual tls

MCP server mutual TLS — client certificate authentication, CA pinning, and SPIFFE

Mutual TLS (mTLS) is the authentication mechanism of choice for MCP servers deployed in zero-trust environments where bearer tokens and API keys are insufficient. Unlike one-way TLS (which only authenticates the server to the client), mTLS authenticates both parties: the server proves its identity with a certificate, and the client must present a valid certificate signed by a trusted CA before the connection is accepted. For MCP servers running in service meshes, Kubernetes clusters, or enterprise agent platforms, mTLS provides cryptographic workload identity with no shared secrets. Four patterns matter: client certificate validation, CA pinning, SPIFFE SVID integration, and certificate rotation without connection drops.

Pattern 1: Client certificate validation in Node.js

The critical settings are requestCert (ask the client for a certificate) and rejectUnauthorized (reject connections where the client presents no certificate or an untrusted one). Setting rejectUnauthorized: false enables one-way TLS only — which provides encryption but not authentication. This is the default behavior if you don't set these options.

import https from 'https'
import fs from 'fs'

// WRONG: rejectUnauthorized disabled — mTLS is not enforced
const server = https.createServer({
  cert: fs.readFileSync('/certs/server.crt'),
  key: fs.readFileSync('/certs/server.key'),
  requestCert: true,
  rejectUnauthorized: false,  // ← client cert is requested but NOT required
}, app)

// CORRECT: full mTLS enforcement
const server = https.createServer({
  cert: fs.readFileSync('/certs/server.crt'),
  key: fs.readFileSync('/certs/server.key'),
  ca: fs.readFileSync('/certs/internal-ca.crt'),  // internal CA only
  requestCert: true,
  rejectUnauthorized: true,  // ← connection rejected if client cert is missing or untrusted
}, app)

// In middleware — extract identity from validated client certificate
app.use((req, res, next) => {
  const cert = req.socket.getPeerCertificate()
  if (!cert || !req.client.authorized) {
    res.status(401).json({ error: 'Client certificate required' })
    return
  }
  // cert.subject.CN or cert.subject.URI contains the workload identity
  req.workloadIdentity = cert.subject.URI ?? cert.subject.CN
  next()
})

Pattern 2: CA pinning for internal workloads

The system trust store contains hundreds of public CAs trusted by browsers and operating systems. For workload-to-workload authentication inside a cluster, you want to accept only certificates signed by your internal CA — not any public CA. CA pinning replaces the system trust store with a single CA certificate (or a small set), making it impossible to present a certificate signed by a public CA as a workload identity.

import tls from 'tls'
import fs from 'fs'

// When making outbound requests from your MCP server to internal services,
// pin to the internal CA — reject any certificate not signed by it
const internalCA = fs.readFileSync('/certs/internal-ca.crt')

// For fetch/https.request: use a custom agent with CA pinning
import https from 'https'

const internalAgent = new https.Agent({
  ca: internalCA,           // only trust this CA
  rejectUnauthorized: true, // enforce — never disable for internal mTLS
  cert: fs.readFileSync('/certs/client.crt'),  // our identity
  key: fs.readFileSync('/certs/client.key'),
})

const res = await fetch('https://internal-service.cluster.local/api', {
  agent: internalAgent,
})

// For the Node fetch API (Node 18+):
import { Agent } from 'undici'

const undiciAgent = new Agent({
  connect: {
    ca: internalCA,
    cert: fs.readFileSync('/certs/client.crt'),
    key: fs.readFileSync('/certs/client.key'),
    rejectUnauthorized: true,
  },
})

Pattern 3: SPIFFE SVID integration

SPIFFE (Secure Production Identity Framework For Everyone) provides a standard for workload identity via X.509 SVIDs (SVID = SPIFFE Verifiable Identity Document). Each workload gets a certificate with a SPIFFE URI as the Subject Alternative Name, e.g., spiffe://cluster.local/ns/default/sa/mcp-server. The SPIRE agent on each node handles certificate issuance and rotation — your server doesn't need to manage certificate lifecycle at all.

import { X509Source, createSVIDContext } from '@spiffe/spiffe-js'

// Connect to the SPIRE agent's Workload API (Unix socket)
const source = await X509Source.create({ socketPath: '/run/spire/sockets/agent.sock' })

// Get the current SVID
const svid = await source.getX509SVID()

// Start your HTTPS server with the SVID cert + key
let httpsServer = https.createServer({
  cert: svid.certificates,
  key: svid.privateKey,
  ca: svid.bundle,         // SPIFFE trust bundle — all CAs in the trust domain
  requestCert: true,
  rejectUnauthorized: true,
}, app)

// Watch for SVID updates and rotate without restart
source.on('update', (newSvid) => {
  const ctx = tls.createSecureContext({
    cert: newSvid.certificates,
    key: newSvid.privateKey,
    ca: newSvid.bundle,
  })
  httpsServer.setSecureContext(ctx)  // zero-downtime rotation
})

// Validate peer identity in middleware
app.use((req, res, next) => {
  const cert = req.socket.getPeerCertificate()
  const spiffeId = cert?.subjectaltname?.match(/URI:spiffe:\/\/[^,]+/)?.[0]?.replace('URI:', '')
  // Allowlist check: only accept calls from known workloads
  const ALLOWED_WORKLOADS = new Set([
    'spiffe://cluster.local/ns/agents/sa/orchestrator',
    'spiffe://cluster.local/ns/agents/sa/gateway',
  ])
  if (!spiffeId || !ALLOWED_WORKLOADS.has(spiffeId)) {
    res.status(403).json({ error: 'Workload identity not authorized' })
    return
  }
  req.workloadId = spiffeId
  next()
})

Pattern 4: Certificate rotation without downtime

Certificates expire. An MCP server that requires a restart to rotate certificates has a window during rotation where either the old (about-to-expire) certificate is still serving, or the server is down. Node's TLS module supports setSecureContext() on a running server — the new context applies to new connections, existing connections continue on the old context until they close. This allows zero-downtime rotation.

// Watch certificate files for changes (for file-based cert management)
import chokidar from 'chokidar'

const CERT_PATH = '/certs/server.crt'
const KEY_PATH = '/certs/server.key'
const CA_PATH = '/certs/internal-ca.crt'

function reloadCerts(server: https.Server) {
  try {
    const ctx = tls.createSecureContext({
      cert: fs.readFileSync(CERT_PATH),
      key: fs.readFileSync(KEY_PATH),
      ca: fs.readFileSync(CA_PATH),
    })
    server.setSecureContext(ctx)
    // Existing connections continue on old context
    // New connections use the new context immediately
  } catch (err) {
    // Log but don't crash — old context still serves until fixed
    console.error('Certificate reload failed:', err)
  }
}

// Check before expiry — rotate when < 24 hours remain
function scheduleRotationCheck(server: https.Server) {
  setInterval(() => {
    const cert = new tls.TLSSocket(server).getCertificate()
    const expiresAt = new Date(cert.valid_to).getTime()
    const hoursLeft = (expiresAt - Date.now()) / 3_600_000
    if (hoursLeft < 24) reloadCerts(server)
  }, 60 * 60 * 1000)  // check hourly
}

What SkillAudit checks

See also

Check your TLS configuration for mTLS and certificate validation findings.

Run a free audit → How grading works →