Blog
6 min read

Mutual TLS (mTLS) Explained: When the Server Checks You Too

In normal TLS only the server proves its identity. With mutual TLS the client presents a certificate too. How the mTLS handshake works, running a private CA, issuing and rotating short-lived client certificates, configuring Nginx and Node, mTLS in service meshes and webhooks, and the operational traps.

When your browser connects to a website over HTTPS, the server proves who it is with a certificate. The client stays anonymous at the TLS layer; it proves its identity later, if at all, with a password, cookie or API key. (What is HTTPS)

Mutual TLS (mTLS) makes authentication two-way. The server also asks the client for a certificate, verifies it against a CA it trusts, and refuses the connection if the client can't prove it holds the matching private key. Identity is established before a single byte of HTTP is exchanged.

The handshake, with the extra step

In a TLS 1.3 handshake (TLS handshake explained):

  1. Client sends ClientHello.
  2. Server responds with ServerHello, its Certificate, CertificateVerify (a signature proving it holds the key) and — for mTLS — a CertificateRequest listing acceptable signature algorithms and, optionally, CA names.
  3. Client verifies the server's certificate as usual.
  4. Client sends its own Certificate and CertificateVerify, a signature over the handshake transcript made with the client's private key.
  5. Server validates the client chain against its configured client CA, checks validity dates and (optionally) revocation, and verifies the signature.

If the client sends no certificate, or one the server doesn't trust, the server aborts the handshake. The application never sees the request.

What mTLS gives you — and what it doesn't

Gives you:

  • Strong client authentication tied to a private key that never crosses the wire. Stolen traffic logs don't contain a reusable secret, unlike a bearer token.
  • Protection that sits below the application, so a forgotten auth check on one route doesn't expose it.
  • Encryption in both directions, of course.

Doesn't give you:

  • Authorisation. mTLS says "this is the billing service"; your code still decides whether the billing service may call DELETE /users. Read the client identity from the certificate and check it.
  • User identity. Certificates identify machines or services well; issuing them to end users' browsers is painful and rarely worth it.
  • Protection if the client's private key is stolen from disk. Short lifetimes are the answer to that.

Where mTLS is actually used

  • Service-to-service traffic in a private network or cluster — the "zero trust" idea that being inside the network shouldn't be enough. This is what limits the damage of SSRF and lateral movement. (SSRF explained, Man-in-the-middle attacks)
  • Service meshes (Istio, Linkerd) that inject sidecars or node proxies which do mTLS transparently between every pod, with automatic certificate rotation.
  • B2B APIs and payment networks, where partners are issued client certificates.
  • Cloudflare Authenticated Origin Pulls, where your origin only accepts connections presenting Cloudflare's client certificate, so attackers can't bypass the proxy by hitting your IP.
  • IoT devices, each provisioned with its own certificate.
  • Database connections — Postgres supports clientcert=verify-full in pg_hba.conf.

Running a private CA

Don't use a public CA for client certificates. Run your own: you control who gets one, and your servers trust only it.

A minimal setup with OpenSSL, for learning:

# CA key and self-signed CA cert (keep ca.key offline / in a secrets manager)
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout ca.key -out ca.crt -days 3650 -subj "/CN=Internal Client CA"

# Client key + CSR
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout billing.key -out billing.csr -subj "/CN=billing-service"

# Sign it: short validity, client-auth usage only
cat > client.ext <<'EOF'
extendedKeyUsage = clientAuth
subjectAltName = URI:spiffe://internal/billing-service
EOF
openssl x509 -req -in billing.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out billing.crt -days 30 -extfile client.ext

In production, use a tool built for it — step-ca, HashiCorp Vault's PKI engine, a cloud private CA, or the mesh's own CA — so issuance and renewal are automated. Key points:

  • Set extendedKeyUsage = clientAuth so a client cert can't be used as a server cert.
  • Put a stable identity in the SAN. SPIFFE IDs (spiffe://trust-domain/workload) are a widely used convention.
  • Keep a separate CA for client certificates from the one issuing server certificates.

Configuring the server

Nginx:

server {
    listen 443 ssl;
    ssl_certificate         /etc/nginx/tls/server.crt;
    ssl_certificate_key     /etc/nginx/tls/server.key;

    ssl_client_certificate  /etc/nginx/tls/client-ca.crt;   # who may connect
    ssl_verify_client       on;                             # or "optional"
    ssl_verify_depth        2;

    location / {
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_set_header X-Client-DN     $ssl_client_s_dn;
        proxy_pass http://127.0.0.1:3000;
    }
}

If you forward identity in headers like this, make sure the backend is only reachable through Nginx and that Nginx overwrites (never passes through) those headers — otherwise anyone can forge them. (Nginx reverse proxy config)

Node.js, terminating mTLS directly:

import https from 'node:https'
import fs from 'node:fs'

https.createServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt'),
  ca: fs.readFileSync('client-ca.crt'),
  requestCert: true,
  rejectUnauthorized: true,
}, (req, res) => {
  const peer = req.socket.getPeerCertificate()
  const id = peer.subjectaltname  // e.g. "URI:spiffe://internal/billing-service"
  if (id !== 'URI:spiffe://internal/billing-service') {
    res.writeHead(403).end(); return
  }
  res.end('ok')
}).listen(8443)

The client:

curl --cert billing.crt --key billing.key --cacert server-ca.crt https://api.internal:8443/

The operational traps

Rotation. The most common mTLS outage is a client certificate expiring at 3 a.m. Use short lifetimes (hours to weeks) with automated renewal, reload certificates without restarts, and alert well before expiry. Long-lived certificates "to avoid the hassle" just postpone the outage and widen the window for a stolen key.

Revocation. CRLs and OCSP for private CAs are awkward to run. Short-lived certificates make revocation mostly unnecessary: a compromised cert simply expires soon. If you need immediate cut-off, deny-list the identity in your authorisation layer.

TLS-terminating proxies in the middle. A load balancer that terminates TLS ends the mTLS session there. Either pass TLS through (TCP mode) to the backend, or have the proxy verify the client and forward identity over a trusted, authenticated hop.

Debugging. Handshake failures are opaque to the application. Use openssl s_client -connect host:443 -cert c.crt -key c.key -state and read the server's TLS error log; common causes are the wrong CA bundle, a missing intermediate, a cert without clientAuth, or clock skew.

Trusting the whole CA. If any certificate from your CA can call any service, one leaked key opens everything. Authorise on the identity in the certificate, per service.

mTLS vs API keys vs signed requests

API key / bearer token Signed requests (HMAC) mTLS
Secret sent on the wire Yes No No
Works through any proxy Yes Yes Needs passthrough or re-termination
Setup effort Low Medium Higher (PKI, rotation)
Good for Public APIs, simple integrations Webhooks Internal services, partners, devices

For a single app on a single server, mTLS is usually overkill — keep internal services on localhost or a private network and use tokens. It earns its keep when you have many services, multiple networks, or partners who need strong, key-based identity. (Handle webhooks reliably, API keys for your users)


EasySpawn handles public TLS certificates for your apps automatically, and Claude Code on the server can help you set up a private CA and Nginx client verification when you do need mTLS. See how it works or join the waitlist.

Related: TLS Handshake Explained · Man-in-the-Middle Attacks · SSRF Explained · JWKS and Signing Key Rotation

Keep reading