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
- Client connects; server sends its certificate.
- Server sends a CertificateRequest.
- Client sends its certificate and proves possession of the matching private key by signing the handshake transcript.
- Server validates the chain, expiry, revocation, and usually the subject name.
- 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
# 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.
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.
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