Mutual TLS (Client Certificates)

Push authentication down into the TLS handshake itself: the client proves its identity with a certificate before a single HTTP byte is sent.

Ordinary TLS authenticates the server: your browser checks that dfg.com presents a certificate signed by a CA it trusts. Mutual TLS adds the mirror image — the server demands a certificate from the client and verifies it against a CA it trusts.

The handshake

  1. Client connects; server sends its certificate.
  2. Server sends a CertificateRequest.
  3. Client sends its certificate and proves possession of the matching private key by signing the handshake transcript.
  4. Server validates the chain, expiry, revocation, and usually the subject name.
  5. Only now does the HTTP request happen — on a connection whose peer is already identified.

Why it is strong

  • No shared secret in transit. The private key never leaves the client.
  • Nothing to phish or replay. There is no token to steal from a log.
  • Identity is bound to the connection, so every request on it inherits it.

Why it is not everywhere

Certificate lifecycle. You need a private CA, an issuance process, distribution to every client, revocation (CRL/OCSP), and renewal before expiry — and an expired client certificate is a total outage, not a degraded experience. Browsers also handle client certificates poorly: the selection prompt is ugly and unskinnable, which rules mTLS out for consumer web apps.

Where it belongs

  • Service-to-service inside a cluster — this is what a service mesh (Istio, Linkerd) automates, rotating certificates every few hours so you never think about them.
  • Regulated APIs — open banking, healthcare, payment networks frequently mandate it.
  • High-value B2B integrations with a handful of partners.

Terminating at a proxy

Usually mTLS ends at the load balancer, which forwards the verified subject in a header such as X-SSL-Client-S-DN. That is fine — as long as your app is unreachable except through that proxy and the proxy strips any incoming copy of that header. Otherwise anyone can simply send it themselves.

Example

Example · bash
# Private CA
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
  -keyout ca-key.pem -out ca.pem -subj "/CN=Internal CA"

# Client key + CSR
openssl req -newkey rsa:2048 -nodes \
  -keyout client-key.pem -out client.csr -subj "/CN=orders-service"

# CA signs the client certificate
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem \
  -CAcreateserial -days 90 -out client.pem

# Call the API with it
curl https://dfg.com/api/internal/orders \
  --cert client.pem --key client-key.pem --cacert ca.pem

# Without the certificate the TLS handshake itself fails —
# the request never reaches your application code.

When to use it

  • Two internal services authenticate with certificates rotated hourly by a service mesh, so a stolen certificate is useless within the hour.
  • An open-banking API requires mTLS by regulation, and the bank pins each partner's certificate subject to a specific client account.
  • A payment terminal fleet ships with per-device certificates so a cloned device can be revoked individually without touching the others.

More examples

Requiring and reading the client certificate (Node)

requestCert alone only asks; rejectUnauthorized is what actually refuses unverified peers. Setting the first without the second is a common misconfiguration.

Example · javascript
import https from 'https';
import fs from 'fs';
import express from 'express';

const app = express();

app.get('/api/internal/orders', (req, res) => {
  const cert = req.socket.getPeerCertificate();
  if (!req.socket.authorized) {
    return res.status(401).json({ error: 'client_certificate_required' });
  }
  // The CN is the service identity — authorize on it, do not just log it.
  const service = cert.subject.CN;                 // 'orders-service'
  if (!ALLOWED_SERVICES.has(service)) {
    return res.status(403).json({ error: 'service_not_permitted' });
  }
  res.json({ callerService: service, data: orders() });
});

https.createServer({
  key:  fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server.pem'),
  ca:   fs.readFileSync('ca.pem'),   // CA that must have signed the client cert
  requestCert: true,
  rejectUnauthorized: true,          // drop the connection if it does not verify
}, app).listen(8443);

Terminating at nginx and forwarding the identity

Blanking the header before setting it is the whole security of this pattern — without that line, any client can claim any identity by sending the header itself.

Example · bash
server {
    listen 443 ssl;
    server_name dfg.com;

    ssl_certificate     /etc/ssl/server.pem;
    ssl_certificate_key /etc/ssl/server-key.pem;

    ssl_client_certificate /etc/ssl/ca.pem;
    ssl_verify_client on;          # refuse connections without a valid cert
    ssl_verify_depth 2;

    location /api/internal/ {
        # Strip anything the client tried to send, then set the verified values.
        proxy_set_header X-SSL-Client-DN     "";
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
        proxy_set_header X-SSL-Client-DN     $ssl_client_s_dn;
        proxy_pass http://app:3000;
    }
}

Discussion

  • Be the first to comment on this lesson.