The Practical Developer

Zero-Trust API Security with mTLS: When the Network Perimeter Is a Lie

Your internal APIs are wide open: any compromised container or misconfigured pod can call any service without proving its identity. Here is how to deploy mutual TLS between Node.js services with certificate auto-rotation, no shared secrets, and under 100 lines of glue code.

A dimly lit data center corridor with rows of server racks, the kind of physical infrastructure where the assumption that "internal means trusted" quietly breaks

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.

How mTLS changes the trust model

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.

The certificate hierarchy

You need three layers of certificates:

  1. Root CA certificate — the anchor of trust. Both sides must trust this CA. Keep the private key offline or in a secrets manager. Sign only intermediate CAs with it, never service certificates directly.
  2. Intermediate CA certificate — signed by the root. This is what you actually use to issue service certificates. If it is compromised, you rotate only the intermediate, not the root.
  3. Service certificates — one per service (or per instance), signed by the intermediate. Each certificate has a CN that identifies the service, an expiration date, and optionally SANs for multiple hostnames.

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).

Generating the certificates

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.

The Node.js server: requiring client certificates

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 Node.js client: presenting a certificate

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.

Extracting the caller identity

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.

Certificate rotation without downtime

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
  }
});

Performance: what mTLS actually costs

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:

  • HTTP/2 with connection reuse: mTLS adds about 0.1% to total latency. Negligible.
  • HTTP/1.1 with keep-alive: similar to HTTP/2.
  • HTTP/1.1 without keep-alive: each request pays the handshake cost. mTLS adds measurable latency, but you should not be running without keep-alive in production anyway.

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

Running in Kubernetes without a service mesh

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:

  1. A cert-manager Issuer or ClusterIssuer that signs service certificates. Use cert-manager.io with a self-signed CA or Vault PKI backend.
  2. A Certificate resource per service that requests a cert with the service name as the CN.
  3. A volume mount that makes the certificate files available to the pod.
  4. A startup probe that waits for the certificate files to exist before marking the pod ready.

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.

When not to use mTLS

mTLS is not the right tool for every situation.

  • Public-facing APIs. Your customers cannot present a certificate issued by your internal CA. Use OAuth2, API keys, or JWTs for external callers.
  • Short-lived connections to many different hosts. If your service makes thousands of unique outbound connections per second to different targets, the handshake overhead adds up. Use connection pooling and keep-alive to reuse connections, or consider a service mesh that handles mTLS at the proxy layer with connection reuse across all targets.
  • Serverless functions. Lambda and Cloud Run functions are ephemeral. They cannot maintain long-lived TLS sessions because the runtime is recycled between invocations. Use HMAC request signing or OAuth2 client credentials instead.
  • Legacy services that cannot be modified. If you cannot add certificate verification to a service, put it behind a proxy (nginx, Envoy) that terminates mTLS and forwards plain HTTP to the legacy process. The proxy handles the auth, and the legacy service sees only trusted traffic.

The takeaway

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.


A note from Yojji

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.