HTTPS & TLS

intermediate nodejs https tls security

https is http plus TLS — same API, same req/res shape, but the bytes on the wire are encrypted. In real production we almost never expose Node’s HTTPS server directly; a reverse proxy (Caddy, Nginx, ALB) handles TLS and forwards plain HTTP to our app. But knowing the raw module matters when we build internal mTLS services, talk to a third-party API with a custom cert, or troubleshoot why our fetch says “self-signed certificate.”

In simple language: TLS is the encryption layer between TCP and HTTP. We give Node a private key + certificate, it does the handshake with clients, and our handler code sees a normal request.

A minimal HTTPS server

import https from 'node:https';
import { readFileSync } from 'node:fs';

const server = https.createServer({
  key: readFileSync('./certs/server.key'),
  cert: readFileSync('./certs/server.crt'),
}, (req, res) => {
  res.writeHead(200);
  res.end('Hello over TLS');
});

server.listen(8443);

Same (req, res) handler as plain HTTP. The only difference is the options object with key and cert.

Where the cert comes from

For local dev we generate a self-signed cert with mkcert (handles the trust store dance):

mkcert -install
mkcert localhost 127.0.0.1
# produces localhost.pem and localhost-key.pem

For production we get certs from Let’s Encrypt (via certbot or Caddy), or from a cloud-managed cert service. Don’t ship self-signed certs to production — clients will refuse the connection unless explicitly told to ignore.

The TLS handshake — what’s happening

Client Hello (supported ciphers)
Server Hello + Certificate
verify cert against trusted CA
Key exchange
Key exchange
✓ Encrypted channel established
HTTP traffic flows

The client validates that our cert is signed by a CA it trusts and that the hostname matches. That’s where most TLS pain comes from.

Mutual TLS (mTLS) — the client proves who it is too

In normal HTTPS, only the server presents a cert. In mutual TLS, the client also presents a cert, and the server validates it. This is how zero-trust internal services authenticate without API keys — Kubernetes service meshes, AWS IAM Roles Anywhere, Stripe’s payment terminal API.

import https from 'node:https';
import { readFileSync } from 'node:fs';

const server = https.createServer({
  key: readFileSync('./certs/server.key'),
  cert: readFileSync('./certs/server.crt'),
  ca: readFileSync('./certs/client-ca.crt'),  // CA we trust to sign client certs
  requestCert: true,    // ask client for a cert
  rejectUnauthorized: true, // close connection if client cert is invalid
}, (req, res) => {
  const cert = req.socket.getPeerCertificate();
  res.end(`Hello, ${cert.subject.CN}`);
});

server.listen(8443);

The client must present a cert signed by client-ca.crt. The server then knows exactly who’s calling.

Calling an mTLS server as a client

Same idea, other direction:

import https from 'node:https';

const req = https.request({
  hostname: 'internal-api.local',
  port: 8443,
  path: '/data',
  method: 'GET',
  key: readFileSync('./certs/client.key'),
  cert: readFileSync('./certs/client.crt'),
  ca: readFileSync('./certs/server-ca.crt'), // CA that signed the server's cert
}, (res) => {
  res.pipe(process.stdout);
});
req.end();

Common pitfalls

UNABLE_TO_VERIFY_LEAF_SIGNATURE — the server’s cert chain is incomplete. The fix is on the server side: include intermediate certs in the chain, not just the leaf. We can also point Node at extra CAs:

NODE_EXTRA_CA_CERTS=/path/to/corporate-root.pem node app.js

SELF_SIGNED_CERT_IN_CHAIN in dev — we’re calling our own self-signed server. We tell our HTTP client to trust it via ca: option. Do not set NODE_TLS_REJECT_UNAUTHORIZED=0 in production — it disables all cert checking globally and is a giant security hole.

Hostname mismatch — the cert is for api.example.com but we’re connecting to 1.2.3.4. TLS verifies the hostname. Either use the domain name, or configure the cert with a Subject Alternative Name for the IP.

Cert expiry — Let’s Encrypt certs last 90 days. If we forget to renew, the entire service goes down. Use auto-renewal (Caddy does this for free) and monitor expiry.

When to terminate TLS in Node vs at a proxy

Honestly, most of the time we put a reverse proxy in front of Node — it handles TLS, our app speaks plain HTTP internally. The proxy does cert renewal, HTTP/2, compression, often better than Node would. We reach for Node’s HTTPS when:

  • We need mTLS at the application layer (auth tied to cert).
  • We’re building a CLI tool or background worker that talks to a TLS-protected internal service.
  • We’re writing a webhook receiver for a service that requires TLS to a specific hostname we own.

The mental model

https is http plus a { key, cert } options bag. Cert + private key go on the server. Trusted CAs go on whoever’s verifying. mTLS just means both sides present a cert. When something breaks, 90% of the time it’s a hostname mismatch, missing intermediate cert, or expired cert — not Node’s fault.