






















Your payment service and user service talk over an internal HTTP API. The network is private. The cluster has network policies. You are behind a VPN. That should be enough, right?
Then someone pushes a misconfigured sidecar, a staging container starts resolving production service DNS, or a developer’s laptop gets SSH access to a pod for debugging. Suddenly any process on the network can call your internal billing API and the response is a clean 200. No credentials checked. No identity verified. The network perimeter was the only guard, and it failed silently.
This is the problem mutual TLS (mTLS) solves. Every service presents an X.509 certificate when it connects, and the receiving service verifies that the certificate was issued by a trusted Certificate Authority (CA) and that it has not been revoked. The network does not need to be trusted. The IP does not need to be known. The identity is cryptographic and portable.
This post covers the full mTLS setup for Node.js: generating a CA, issuing service certificates, configuring both server and client sides, and rotating certificates without downtime. No service mesh required. No sidecar necessary. Just Node’s built-in tls module and a few files.
Regular TLS (the kind every HTTPS connection uses) is one-way. The client verifies the server’s certificate. The server does not verify the client’s identity. This is fine for e-commerce: you want the customer to know they are talking to the real store, but the store does not need a certificate proving which customer is connecting.
In a microservice architecture, that asymmetry is backwards. The service receiving the request needs to know exactly which caller is on the other end, because access control decisions (can service A read user PII? can service B write to the audit log?) depend on the caller’s identity, not the target’s.
mTLS flips the protocol. During the TLS handshake, the server sends its certificate (just like regular TLS), but it also requests a certificate from the client. The client sends one. The server verifies it against a trusted CA list. If the client’s certificate is missing, expired, or signed by an unknown CA, the handshake fails before a single HTTP byte is exchanged.
The result is that every connection carries a cryptographically verified identity. You can extract the Common Name (CN) or Subject Alternative Name (SAN) from the client certificate and use it as the caller’s identity for authorization decisions. No tokens. No shared secrets. No API keys in environment variables.
You need three layers of certificates:
Generate the root CA once. Generate one intermediate per environment (dev, staging, production). Generate one service certificate per deployment or per instance, with a short lifetime (24-72 hours).
Use a script that wraps openssl. Do not generate certificates by hand for every service. Automate it from day one.
#!/usr/bin/env bash
# generate-ca.sh -- Run once per environment.
set -euo pipefail
ROOT_DIR="${1:-./certs}"
# Root CA
openssl genrsa -out "$ROOT_DIR/ca.key" 4096
openssl req -x509 -new -nodes -key "$ROOT_DIR/ca.key" \
-sha256 -days 3650 \
-subj "/CN=The Practical Developer Root CA" \
-out "$ROOT_DIR/ca.crt"
# Intermediate CA
openssl genrsa -out "$ROOT_DIR/intermediate.key" 4096
openssl req -new -key "$ROOT_DIR/intermediate.key" \
-subj "/CN=The Practical Developer Intermediate CA" \
-out "$ROOT_DIR/intermediate.csr"
openssl x509 -req -in "$ROOT_DIR/intermediate.csr" \
-CA "$ROOT_DIR/ca.crt" -CAkey "$ROOT_DIR/ca.key" \
-CAcreateserial -days 1825 -sha256 \
-out "$ROOT_DIR/intermediate.crt"
echo "Root CA: $ROOT_DIR/ca.crt"
echo "Intermediate: $ROOT_DIR/intermediate.crt"
Then issue a service certificate:
#!/usr/bin/env bash
# issue-cert.sh -- Run for each service instance.
set -euo pipefail
SERVICE="$1"
CERT_DIR="${2:-./certs}"
openssl genrsa -out "$CERT_DIR/$SERVICE.key" 2048
openssl req -new -key "$CERT_DIR/$SERVICE.key" \
-subj "/CN=$SERVICE" \
-out "$CERT_DIR/$SERVICE.csr"
# Issue a cert valid for 30 days
openssl x509 -req -in "$CERT_DIR/$SERVICE.csr" \
-CA "$CERT_DIR/intermediate.crt" -CAkey "$CERT_DIR/intermediate.key" \
-CAcreateserial -days 30 -sha256 \
-out "$CERT_DIR/$SERVICE.crt"
echo "Cert issued: $CERT_DIR/$SERVICE.crt"
echo "Key: $CERT_DIR/$SERVICE.key"
Run ./issue-cert.sh payment-service and you get payment-service.crt and payment-service.key. The CN is set to payment-service. That is the identity that the receiving service will extract and use for authorization.
A normal HTTPS server in Node.js does not request a client certificate. You need to enable the requestCert and ca options in the tls layer.
// server.ts
import https from 'node:https';
import fs from 'node:fs';
import express from 'express';
const app = express();
app.get('/api/orders', (req, res) => {
// The client certificate is available on the socket
const cert = (req.socket as any).getPeerCertificate();
const serviceName = cert.subject?.CN ?? 'unknown';
console.log(`Request from: ${serviceName}`);
res.json({ service: serviceName, data: [] });
});
const server = https.createServer({
key: fs.readFileSync('./certs/payment-service.key'),
cert: fs.readFileSync('./certs/payment-service.crt'),
ca: fs.readFileSync('./certs/intermediate.crt'),
requestCert: true, // Ask the client for a certificate
rejectUnauthorized: true, // Reject if the client cert is invalid
}, app);
server.listen(443, () => {
console.log('mTLS server listening on port 443');
});
The two critical options are requestCert and rejectUnauthorized. If you set rejectUnauthorized: false, the handshake succeeds even with a missing or invalid client certificate. This is useful during a gradual rollout, but in production it defeats the purpose. Start with true and add an explicit allowlist for services that have not migrated yet.
The client side needs to load its own certificate and key, and also trust the CA that signed the server’s certificate.
// client.ts
import https from 'node:https';
import fs from 'node:fs';
async function callService(url: string): Promise<unknown> {
const agent = new https.Agent({
key: fs.readFileSync('./certs/user-service.key'),
cert: fs.readFileSync('./certs/user-service.crt'),
ca: fs.readFileSync('./certs/intermediate.crt'),
});
const res = await fetch(url, { agent });
// The server's certificate is available on the agent's socket
// but fetch does not expose it directly. For extraction, use
// the lower-level https.request API instead.
return res.json();
}
Every service pair shares the same intermediate CA. The user service trusts the intermediate CA, so it accepts the payment service’s certificate. The payment service trusts the same intermediate CA, so it accepts the user service’s certificate. No per-service trust configuration needed.
Once the client certificate is verified, you need to extract the caller’s identity to make authorization decisions.
// auth-middleware.ts
import type { Request, Response, NextFunction } from 'express';
import type { PeerCertificate } from 'node:tls';
export interface AuthenticatedRequest extends Request {
callerService?: string;
callerCert?: PeerCertificate;
}
export function mTlsAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const socket = req.socket;
if (!socket.authorized) {
res.status(403).json({ error: 'unauthorized', message: 'Client certificate required' });
return;
}
const cert = socket.getPeerCertificate();
if (!cert || !cert.subject) {
res.status(403).json({ error: 'unauthorized', message: 'Invalid client certificate' });
return;
}
req.callerService = cert.subject.CN;
req.callerCert = cert;
next();
}
Then use it in your routes:
app.get('/api/users/:id', mTlsAuth, (req: AuthenticatedRequest, res) => {
// Only the user-service is allowed to read user data
if (req.callerService !== 'user-service') {
res.status(403).json({ error: 'forbidden' });
return;
}
// ... fetch user data
});
This is coarse-grained authorization. For fine-grained rules, embed roles or permissions as certificate extensions (custom OID fields) and extract them in the middleware.
Certificates expire. If you issue them with a 30-day lifetime, you need to rotate them before day 30. Doing this manually is how outages happen. Automate it.
The pattern is a background timer that reloads the certificate and key files, then replaces the server’s TLS context on the fly:
// cert-reloader.ts
import fs from 'node:fs';
import https from 'node:https';
import type { Server } from 'node:https';
interface CertBundle {
key: string;
cert: string;
ca: string;
}
export function setupCertReloader(
server: Server,
paths: { key: string; cert: string; ca: string },
intervalMs = 3600_000, // every hour
): void {
let currentBundle: CertBundle = {
key: fs.readFileSync(paths.key, 'utf8'),
cert: fs.readFileSync(paths.cert, 'utf8'),
ca: fs.readFileSync(paths.ca, 'utf8'),
};
setInterval(() => {
try {
const newBundle: CertBundle = {
key: fs.readFileSync(paths.key, 'utf8'),
cert: fs.readFileSync(paths.cert, 'utf8'),
ca: fs.readFileSync(paths.ca, 'utf8'),
};
// Validate before applying
const ctx = server.setSecureContext(newBundle);
currentBundle = newBundle;
console.log('TLS certificates rotated successfully');
} catch (err) {
console.error('Certificate rotation failed, keeping existing certs:', err);
}
}, intervalMs);
}
Wire it into the server startup:
const server = https.createServer({ ... }, app);
server.listen(443);
setupCertReloader(server, {
key: './certs/payment-service.key',
cert: './certs/payment-service.crt',
ca: './certs/intermediate.crt',
});
Now a cron job or Kubernetes init container copies new certificate files into the expected paths, and the server picks them up within an hour without restarting. No dropped connections. No handshake failures.
For environments where even an hour of stale cert is too long, use fs.watch instead of a timer:
fs.watch('./certs', (eventType, filename) => {
if (filename?.endsWith('.crt') || filename?.endsWith('.key')) {
// trigger reload
}
});
Every mTLS handshake requires asymmetric cryptography (RSA 2048-bit signatures or ECDSA). This adds 2-10ms to the initial connection setup compared to a plain TCP connection. After the handshake, the session uses symmetric encryption and there is zero overhead per request for the lifetime of the connection.
For long-lived HTTP/2 or gRPC connections, the handshake cost is amortized over thousands of requests. For short-lived connections (one request, then close), the overhead matters more. In practice:
Use ECDSA certificates instead of RSA. ECDSA P-256 keys are faster to sign and verify than RSA 2048, and the handshake completes in about half the time:
# Generate an ECDSA P-256 key instead of RSA
openssl ecparam -genkey -name prime256v1 -out service.key
openssl req -new -key service.key -subj "/CN=payment-service" -out service.csr
If you are using a service mesh (Istio, Linkerd, Consul Connect), mTLS is handled transparently by the sidecar proxy. But if you do not want the operational overhead of a mesh, or if your cluster is small enough that a mesh is overkill, you can run mTLS at the application level the way this post describes.
The key pieces you need in Kubernetes:
cert-manager.io with a self-signed CA or Vault PKI backend.A minimal cert-manager setup:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: internal-ca
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: payment-service-tls
spec:
secretName: payment-service-tls
commonName: payment-service
duration: 720h # 30 days
renewBefore: 48h
issuerRef:
name: internal-ca
kind: Issuer
The certificate and key get written to a Secret named payment-service-tls, which you mount into the pod. cert-manager handles renewal automatically. Your Node.js process picks up the new cert via the file-watch reloader.
mTLS is not the right tool for every situation.
Network perimeters are a leaky abstraction. Any container, any compromised dependency, any misconfigured network policy can turn “internal” into “hostile.” mTLS replaces network trust with cryptographic identity. Every connection carries a verified caller name. Every authorization decision uses that name, not an IP address or a network boundary.
The implementation is under 100 lines of application code: a server that requires client certificates, a client that presents them, and a file-watch reloader that rotates credentials without downtime. The hardest part is the one-time CA setup. After that, issuing a certificate for a new service is a single script invocation.
Generate the CA today. Issue a cert for two services. Wire the server and client sides. You will sleep better knowing that a compromised pod in the same cluster can authenticate to exactly zero services it does not own.
Implementing mTLS across a microservice fleet is exactly the kind of infrastructure work that teams plan to do “next sprint” and then never prioritize until an incident forces it. The certificate lifecycle, the CA management, the rotation automation, and the middleware changes all need to be coordinated across services.
Yojji is an international custom software development company with offices across Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architecture, delivering everything from security infrastructure to full-cycle product builds since 2016.
If your team has been meaning to lock down internal service communication but cannot find the sprint capacity, Yojji is worth a conversation.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。