Every HTTPS connection your Node.js server makes starts with a TLS handshake. That handshake negotiates which cipher to use, verifies the server certificate, and establishes the symmetric key that encrypts your data. TLS 1.3, finalized in RFC 8446, cuts that handshake from two round trips down to one, drops every cipher suite that lacks forward secrecy, and encrypts the certificate exchange so passive observers cannot even see who issued your cert. This tutorial walks you through 12 concrete steps to configure TLS 1.3 in Node.js, from a self-signed cert for localhost all the way to zero-downtime renewal in production.
Node.js 22 ships with OpenSSL 3.x, which fully supports TLS 1.3 with X25519 key exchange and the AEAD-only cipher suites mandated by the RFC. You will not need any third-party TLS library: the built-in tls and https modules expose every option you need. Estimated time: 30 minutes on a fresh Linux or macOS machine.
What the TLS Handshake Actually Does
Before touching code, it helps to know exactly what happens during the TLS handshake. The handshake is a short protocol exchange that runs before any application data flows. Its three jobs are authentication (prove the server is who it claims to be), key exchange (agree on a shared secret without transmitting it in the clear), and cipher negotiation (agree on the algorithm used to encrypt the session).
In TLS 1.2, the handshake takes two round trips. The client sends a ClientHello, the server replies with its own Hello plus the chosen algorithm and its certificate, then the client sends a key exchange message, and finally both sides confirm with a Finished message. That sequence means the client must wait for the server to respond before it can send key material, adding a full round trip of latency before the first byte of data moves.
TLS 1.3 collapses that to one round trip by having the client guess which key agreement group the server will prefer and send its own key share speculatively in the ClientHello. The server reads that key share, completes the Diffie-Hellman exchange immediately, and responds with the ServerHello plus every subsequent handshake message in a single flight, all already encrypted. The client only needs to send a Finished message to complete authentication. On resumed connections, TLS 1.3 supports 0-RTT early data, allowing the client to include application data in its very first flight, though this trades replay-attack resistance for raw speed and should be used carefully.
One important privacy improvement in TLS 1.3: the server certificate travels inside EncryptedExtensions, sent after the ServerHello establishes a shared key. In TLS 1.2, the certificate travels in plain text, visible to any network observer. With TLS 1.3, a passive eavesdropper learns only the server name (via the SNI extension in ClientHello, though Encrypted Client Hello can conceal that too) but cannot inspect the certificate chain.
TLS 1.3 vs TLS 1.2: Handshake Speed and Security Gains
Understanding the differences between TLS 1.2 and TLS 1.3 helps you argue the upgrade internally and configure the right fallback policy. The table below summarizes the most important changes.
| Feature | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Full handshake latency | 2 round trips (2-RTT) | 1 round trip (1-RTT) |
| Session resumption | 1-RTT (session tickets) | 0-RTT (early data) |
| Key exchange methods | RSA, DHE, ECDHE | ECDHE only (X25519, P-256, P-384) |
| Forward secrecy | Optional (RSA lacks it) | Mandatory for all connections |
| Certificate visibility | Plain text on the wire | Encrypted after ServerHello |
| Cipher suite count | 37+ (many weak) | 5 (all AEAD, all strong) |
| Renegotiation | Supported | Removed |
| Compression | Supported (CRIME attack risk) | Removed |
| SHA-1 / MD5 | Permitted in some modes | Removed entirely |
| CBC mode ciphers | Permitted (BEAST, POODLE risk) | Removed entirely |
The three TLS 1.3 cipher suites you will encounter in Node.js are TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, and TLS_CHACHA20_POLY1305_SHA256. All three are Authenticated Encryption with Associated Data (AEAD) schemes, which means each message carries an authentication tag that detects tampering without a separate HMAC. On hardware without AES acceleration (older ARM chips, some IoT devices), ChaCha20-Poly1305 is often faster than AES-GCM. Node.js and OpenSSL select among these automatically based on the hardware capabilities of both endpoints.
From a business perspective, the 1-RTT reduction matters most at high latency. On a 100 ms round-trip-time link, TLS 1.3 saves 100 ms on every new connection. On a 20 ms intranet, the savings are smaller but the security gains (mandatory Perfect Forward Secrecy, no CBC, no renegotiation) are equally valuable for any compliance-focused deployment.
Prerequisites: Versions You Need
Before running any command in this tutorial, confirm you have the right versions installed. TLS 1.3 support in Node.js depends on the underlying OpenSSL version, not just Node.js itself.
- Node.js 22.x LTS (minimum: Node.js 12 with OpenSSL 1.1.1; 22 is current LTS as of June 2026)
- OpenSSL 3.x (bundled with Node.js 22; verify with
node -e "console.log(process.versions.openssl)") - OpenSSL CLI for testing (
openssl versionshould show 3.x) - Certbot (optional, for Let’s Encrypt in Steps 6 and 11):
certbot --version - A domain name with DNS pointing to your server (required for Let’s Encrypt; Steps 1 through 10 work without one using a self-signed cert)
- Basic familiarity with the Node.js crypto module and how HTTPS differs from HTTP
Step 1: Install Node.js 22 and Confirm OpenSSL 3.x
Install Node.js 22 LTS using the official package manager or nvm. After installation, verify that both Node.js and its bundled OpenSSL satisfy the TLS 1.3 requirements.
# Install Node.js 22 via nvm
nvm install 22
nvm use 22
# Verify versions
node --version # Should output v22.x.x
node -e "console.log(process.versions.openssl)" # Should output 3.x.x
openssl version # Should output OpenSSL 3.x.x
# Confirm TLS 1.3 cipher suites are available
node -e "
const tls = require('tls');
const tls13 = tls.getCiphers().filter(c =>
c.includes('aes_128_gcm') || c.includes('aes_256_gcm') || c.includes('chacha20')
);
console.log('TLS 1.3-capable ciphers:', tls13.length);
"
If process.versions.openssl shows anything below 1.1.1, TLS 1.3 is not available and you must upgrade Node.js. Any Node.js release from v12 onward ships OpenSSL 1.1.1 at minimum; Node.js 22 ships OpenSSL 3.x, which includes post-quantum algorithm support in its experimental modules alongside mature TLS 1.3 support.
The tls.getCiphers() call lists every cipher OpenSSL knows about. TLS 1.3-specific cipher suite names follow the pattern TLS_AES_* in RFC notation, though Node.js maps them to OpenSSL internal short names in that list. You do not need to memorize the names: Node.js selects TLS 1.3 ciphers automatically when TLS 1.3 is negotiated.
Step 2: Generate a Self-Signed Certificate for Local Development
For local development and testing you need a certificate without going through a certificate authority. The openssl command below generates a 4096-bit RSA key and a certificate valid for 365 days with a Subject Alternative Name (SAN) for localhost. SANs are required by all modern browsers and by the Node.js tls module when hostname verification is enabled.
# Create a directory for local certs
mkdir -p ~/certs && cd ~/certs
# Generate a 4096-bit RSA private key
openssl genrsa -out privkey.pem 4096
# Generate the certificate with a SAN for localhost
openssl req -new -x509 \
-key privkey.pem \
-out cert.pem \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# Verify the certificate contents
openssl x509 -in cert.pem -text -noout | grep -A2 "Subject Alternative Name"
The output of the last command should show:
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1
If you omit the -addext flag and rely solely on the CN field, Node.js clients will throw ERR_TLS_CERT_ALTNAME_INVALID when they connect. This is one of the most common TLS setup mistakes developers make. For local HTTPS testing where you need browser trust without warnings, use mkcert instead: it creates a local CA, installs it in your OS trust store, and generates certs that browsers accept without a click-through warning.
Set restrictive file permissions so the private key is readable only by the server process:
chmod 600 ~/certs/privkey.pem
chmod 644 ~/certs/cert.pem
Step 3: Create Your First TLS 1.3 HTTPS Server
Node.js’s built-in https module wraps tls.createServer() and handles the HTTP protocol on top. The simplest working HTTPS server requires only the cert and key paths:
// server.mjs
import https from 'node:https';
import fs from 'node:fs';
const options = {
key: fs.readFileSync(`${process.env.HOME}/certs/privkey.pem`),
cert: fs.readFileSync(`${process.env.HOME}/certs/cert.pem`),
};
const server = https.createServer(options, (req, res) => {
const tlsSocket = req.socket;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
message: 'Hello from TLS',
protocol: tlsSocket.getProtocol(),
cipher: tlsSocket.getCipher().name,
}));
});
server.listen(8443, () => {
console.log('HTTPS server listening on https://localhost:8443');
});
Run it and verify the connection works:
node server.mjs &
# Test with curl, ignoring cert errors for the self-signed cert
curl -sk https://localhost:8443 | python3 -m json.tool
Expected output:
{
"message": "Hello from TLS",
"protocol": "TLSv1.3",
"cipher": "TLS_AES_256_GCM_SHA384"
}
At this point Node.js negotiates TLS 1.3 automatically because OpenSSL prefers TLS 1.3 when both sides offer it. The -k flag tells curl to skip certificate verification, acceptable for local testing. In Step 9 you will verify the negotiation using the OpenSSL client, which shows the full handshake transcript.
Step 4: Lock to TLS 1.3 with minVersion and maxVersion
By default, Node.js allows TLS 1.2 and TLS 1.3. If your clients all support TLS 1.3 (all modern browsers, Node.js 12+, curl 7.61+, Python 3.7+ with OpenSSL 1.1.1), you can enforce TLS 1.3 exclusively by setting both minVersion and maxVersion:
// server-tls13-only.mjs
import https from 'node:https';
import fs from 'node:fs';
const options = {
key: fs.readFileSync(`${process.env.HOME}/certs/privkey.pem`),
cert: fs.readFileSync(`${process.env.HOME}/certs/cert.pem`),
minVersion: 'TLSv1.3', // reject TLS 1.2 and below
maxVersion: 'TLSv1.3', // leave room to update when TLS 1.4 ships
};
const server = https.createServer(options, (req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`Protocol: ${req.socket.getProtocol()}\n`);
});
server.listen(8443, () => console.log('TLS 1.3-only server on :8443'));
req.socket.getProtocol() returns the string 'TLSv1.3' on a successful connection. If a TLS 1.2 client connects when minVersion: 'TLSv1.3' is set, OpenSSL sends a handshake alert and the connection is rejected before any application code runs. The client receives a protocol error, not a 400 HTTP response, because rejection happens at the TLS layer.
For public-facing APIs where you still need to serve older clients (IE 11, some enterprise proxies), drop maxVersion and set only minVersion: 'TLSv1.2'. TLS 1.2 with ephemeral ECDHE key exchange is still considered secure; the risk rises sharply with TLS 1.0 and 1.1, which Node.js has disabled by default since v12.
Step 5: Configure Cipher Suites and Named Curves
TLS 1.3 cipher suites are fixed by the protocol and cannot be changed via the ciphers option (which applies only to TLS 1.2 and below). However, you can control which elliptic curves are offered for key exchange via the ecdhCurve option, which influences both protocol versions.
// server-ciphers.mjs
import https from 'node:https';
import fs from 'node:fs';
const options = {
key: fs.readFileSync(`${process.env.HOME}/certs/privkey.pem`),
cert: fs.readFileSync(`${process.env.HOME}/certs/cert.pem`),
minVersion: 'TLSv1.2', // allow TLS 1.2 fallback for older clients
// Restrict TLS 1.2 cipher suites (TLS 1.3 uses its own fixed list)
ciphers: [
'ECDHE-ECDSA-AES256-GCM-SHA384', // TLS 1.2 with ECDHE + ECDSA cert
'ECDHE-RSA-AES256-GCM-SHA384', // TLS 1.2 with ECDHE + RSA cert
'ECDHE-ECDSA-CHACHA20-POLY1305', // TLS 1.2 (no hardware AES)
'ECDHE-RSA-CHACHA20-POLY1305', // TLS 1.2 (no hardware AES)
'ECDHE-ECDSA-AES128-GCM-SHA256', // TLS 1.2 fallback
'ECDHE-RSA-AES128-GCM-SHA256', // TLS 1.2 fallback
].join(':'),
// Prefer X25519 for key exchange (fastest modern ECDH curve)
ecdhCurve: 'X25519:P-256:P-384',
// Server picks from the cipher list above, not the client
honorCipherOrder: true,
};
const server = https.createServer(options, (req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
protocol: req.socket.getProtocol(),
cipher: req.socket.getCipher(),
}));
});
server.listen(8443);
The table below compares the three TLS 1.3 cipher suites available in Node.js:
| Cipher Suite | Key Length | MAC | Best On |
|---|---|---|---|
| TLS_AES_128_GCM_SHA256 | 128-bit | AEAD built-in | Intel/AMD with AES-NI hardware acceleration |
| TLS_AES_256_GCM_SHA384 | 256-bit | AEAD built-in | High-security workloads, compliance requiring 256-bit keys |
| TLS_CHACHA20_POLY1305_SHA256 | 256-bit | AEAD built-in | Mobile and IoT devices without hardware AES support |
You cannot disable individual TLS 1.3 cipher suites through the standard ciphers option in Node.js. If a compliance requirement mandates removing 128-bit AES, use the environment variable NODE_OPTIONS='--tls-cipher-list=TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256' before launching the process. This is a process-level setting, not a per-server option.
Step 6: Add a Let’s Encrypt Certificate with Certbot
Self-signed certificates work for local development but browsers display a scary interstitial warning for production traffic. For a production hostname, use a certificate from Let’s Encrypt. Certbot automates both the initial issuance and subsequent 90-day renewals at no cost.
# Install Certbot (Ubuntu / Debian)
sudo apt-get update && sudo apt-get install -y certbot
# Obtain a certificate in standalone mode (Certbot runs its own HTTP server on port 80)
# Your domain's DNS must already point to this machine
sudo certbot certonly --standalone \
--preferred-challenges http \
-d yourdomain.com \
-d www.yourdomain.com
# Let's Encrypt certificate files live at:
# /etc/letsencrypt/live/yourdomain.com/privkey.pem (private key)
# /etc/letsencrypt/live/yourdomain.com/fullchain.pem (cert + intermediate chain)
# /etc/letsencrypt/live/yourdomain.com/cert.pem (leaf cert only, do NOT use this)
# Check certificate expiry
openssl x509 -enddate -noout \
-in /etc/letsencrypt/live/yourdomain.com/cert.pem
Update your Node.js server to read the Let’s Encrypt paths:
// server-production.mjs
import https from 'node:https';
import fs from 'node:fs';
const CERT_DIR = '/etc/letsencrypt/live/yourdomain.com';
const options = {
key: fs.readFileSync(`${CERT_DIR}/privkey.pem`),
cert: fs.readFileSync(`${CERT_DIR}/fullchain.pem`), // MUST be fullchain, not cert.pem
minVersion: 'TLSv1.2',
ecdhCurve: 'X25519:P-256:P-384',
honorCipherOrder: true,
};
const server = https.createServer(options, (req, res) => {
res.writeHead(200);
res.end('Production HTTPS\n');
});
server.listen(443, () => console.log('Production server on :443'));
One critical detail: always load fullchain.pem, not cert.pem. The cert.pem file contains only the leaf certificate. Without the intermediate CA chain, clients that do not already have Let’s Encrypt’s intermediate certificate cached will fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE. The fullchain.pem file concatenates your leaf cert and all intermediate certs so any client can build a complete path to a trusted root CA.
Step 7: Apply HSTS and Secure Response Headers
TLS secures the transport layer, but HTTP Security headers communicate your HTTPS policy to browsers and CDN intermediaries. The most important is HTTP Strict Transport Security (HSTS), which tells browsers to send only HTTPS requests to your domain for a specified period, preventing SSL-stripping attacks where an attacker downgrades your connection from HTTPS to HTTP.
// server-headers.mjs
import https from 'node:https';
import fs from 'node:fs';
const CERT_DIR = '/etc/letsencrypt/live/yourdomain.com';
function addSecurityHeaders(res) {
// Tell browsers to use HTTPS for 1 year, including all subdomains
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
// Prevent clickjacking via iframe embedding
res.setHeader('X-Frame-Options', 'DENY');
// Stop browsers guessing MIME types (reduces XSS attack surface)
res.setHeader('X-Content-Type-Options', 'nosniff');
// Control what referrer information is sent to third parties
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Opt out of Google's FLoC / Topics API tracking
res.setHeader('Permissions-Policy', 'interest-cohort=()');
}
const server = https.createServer(
{
key: fs.readFileSync(`${CERT_DIR}/privkey.pem`),
cert: fs.readFileSync(`${CERT_DIR}/fullchain.pem`),
minVersion: 'TLSv1.2',
},
(req, res) => {
addSecurityHeaders(res);
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Secure response with HSTS\n');
}
);
server.listen(443);
The preload directive in HSTS signals your intent to join the browser HSTS preload list at hstspreload.org, which hardcodes your domain into Chromium, Firefox, and Safari so that even the very first HTTP request is silently upgraded. Submitting to the preload list requires max-age of at least 31536000 (one year) and includeSubDomains. Once listed, removal takes several weeks and multiple browser release cycles, so only add preload if you are committed to HTTPS on all subdomains permanently.
For Express.js applications, the Content Security Policy Node.js tutorial integrates with the HSTS pattern here. Adding Helmet.js as middleware and configuring hsts: { maxAge: 31536000, includeSubDomains: true } in its options achieves the same result with less boilerplate.
Step 8: Enable OCSP Stapling
Certificate revocation tells clients not to trust a certificate before its expiry date. The traditional mechanism requires clients to contact the CA’s OCSP server during the TLS handshake, which adds latency and leaks browsing habits to the CA. OCSP stapling flips this: the server periodically fetches a signed OCSP response from the CA and includes it in the TLS handshake, so the client gets fresh revocation status from your server without a second network round trip to the CA.
// server-ocsp.mjs
import https from 'node:https';
import fs from 'node:fs';
const CERT_DIR = '/etc/letsencrypt/live/yourdomain.com';
const server = https.createServer(
{
key: fs.readFileSync(`${CERT_DIR}/privkey.pem`),
cert: fs.readFileSync(`${CERT_DIR}/fullchain.pem`),
minVersion: 'TLSv1.2',
requestOCSP: true, // enables OCSP stapling mode
},
(req, res) => {
res.writeHead(200);
res.end('OCSP stapling active\n');
}
);
// The OCSPRequest event fires when a client requests OCSP stapling.
// Fetch and cache the CA's OCSP response here, then pass it to callback().
// Third-party packages like 'node-forge' or 'ocsp' handle the fetch logic.
server.on('OCSPRequest', (cert, issuer, callback) => {
// For production: fetch the OCSP response from the CA's responder URL
// embedded in the certificate's AIA extension, then cache it for ~24 h.
// For a minimal deployment behind nginx, let nginx handle OCSP stapling
// instead: ssl_stapling on; ssl_stapling_verify on;
callback(); // null response = no stapling for this example
});
server.listen(443);
In practice, most production Node.js deployments delegate OCSP stapling to a reverse proxy. nginx handles it with two directives (ssl_stapling on; and ssl_stapling_verify on;), and Caddy enables it automatically with zero configuration. If you run Node.js directly on port 443, the ocsp npm package provides a production-ready handler for the OCSPRequest event that fetches, caches, and periodically refreshes the CA’s OCSP response.
Step 9: Test the TLS Handshake with OpenSSL
OpenSSL’s s_client command is the definitive tool for inspecting TLS connections. It shows the full handshake negotiation, the negotiated protocol version, the chosen cipher suite, and the certificate chain presented by the server. Always run this after any configuration change.
# Test that TLS 1.3 is negotiated (server must be running)
openssl s_client -connect localhost:8443 -tls1_3 <<< "Q"
# Attempt TLS 1.2 to confirm it is rejected on a TLS 1.3-only server
openssl s_client -connect localhost:8443 -tls1_2 <<< "Q"
# Get a concise summary of what negotiated
openssl s_client -connect yourdomain.com:443 -brief <<< "Q"
# Show the full certificate chain (check that fullchain.pem is being served)
openssl s_client -connect yourdomain.com:443 -showcerts <<< "Q" 2>/dev/null | \
openssl x509 -noout -subject -issuer
In the output for a successful TLS 1.3 connection, look for:
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
If TLS 1.2 is forced against a TLS 1.3-only server, the output shows an SSL error confirming the rejection:
140...error:0A000102:SSL routines::unsupported protocol
That error confirms your minVersion: 'TLSv1.3' setting is working. The certificate chain check should show your domain as the subject and Let’s Encrypt as the issuer. If the issuer shows your own domain instead, you are serving the self-signed test cert rather than the Let’s Encrypt certificate.
Step 10: Verify with a Node.js TLS Client
Testing from the Node.js side confirms that your application code can connect to your server with certificate verification enabled. This matters because curl’s -k flag bypasses verification, hiding problems that real clients will encounter in production.
// client.mjs
import https from 'node:https';
import fs from 'node:fs';
// For self-signed certs: trust our local CA cert
// For production Let's Encrypt: remove the 'ca' option entirely
const options = {
hostname: 'localhost',
port: 8443,
path: '/',
method: 'GET',
ca: fs.readFileSync(`${process.env.HOME}/certs/cert.pem`),
minVersion: 'TLSv1.3', // require TLS 1.3 from the client too
};
const req = https.request(options, (res) => {
console.log('Status: ', res.statusCode);
console.log('Protocol: ', res.socket.getProtocol());
console.log('Cipher: ', res.socket.getCipher().name);
console.log('Auth: ', res.socket.authorized ? 'verified' : 'unverified');
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => console.log('Body:', body.trim()));
});
req.on('error', (err) => console.error('Connection error:', err.message));
req.end();
Expected output:
Status: 200
Protocol: TLSv1.3
Cipher: TLS_AES_256_GCM_SHA384
Auth: verified
Body: {"message":"Hello from TLS","protocol":"TLSv1.3","cipher":"TLS_AES_256_GCM_SHA384"}
If you see CERT_HAS_EXPIRED, your self-signed certificate’s 365-day validity has passed. Regenerate it with the OpenSSL commands in Step 2. If you see ERR_TLS_CERT_ALTNAME_INVALID, either the cert was generated without the -addext subjectAltName flag, or the hostname in options.hostname does not match the SAN in the cert.
The Node.js crypto module and the tls module share the same OpenSSL backend. Functions like crypto.createSign() for RSA signatures and crypto.createHmac() for HMAC-SHA256 all rely on the same OpenSSL version that powers your TLS connections here.
Step 11: Automate Certificate Renewal and Zero-Downtime Reload
Let’s Encrypt certificates expire after 90 days. Certbot automates renewal, but if your Node.js process caches the certificate in memory at startup, it will continue serving the old (expired) cert until the process restarts. This is the most common TLS production failure: renewed cert on disk, expired cert in memory, users seeing certificate errors starting at the moment of expiry.
The fix is a SIGHUP handler that re-reads the certificate files and calls server.setSecureContext() without restarting the process or dropping existing connections:
// server-reload.mjs
import https from 'node:https';
import fs from 'node:fs';
const CERT_DIR = '/etc/letsencrypt/live/yourdomain.com';
function readCerts() {
return {
key: fs.readFileSync(`${CERT_DIR}/privkey.pem`),
cert: fs.readFileSync(`${CERT_DIR}/fullchain.pem`),
};
}
const server = https.createServer(
{ ...readCerts(), minVersion: 'TLSv1.2' },
(req, res) => { res.writeHead(200); res.end('OK\n'); }
);
server.listen(443, () =>
console.log('Server started, cert loaded at', new Date().toISOString())
);
// Reload certificate on SIGHUP without dropping connections
process.on('SIGHUP', () => {
try {
const newCerts = readCerts();
server.setSecureContext(newCerts);
console.log('Certificate reloaded at', new Date().toISOString());
} catch (err) {
// Log but do not crash: keep serving on the old cert
console.error('Certificate reload failed:', err.message);
}
});
Configure Certbot to send SIGHUP after renewal by adding a deploy hook script:
# /etc/letsencrypt/renewal-hooks/deploy/reload-node.sh
#!/bin/bash
PID=$(cat /var/run/myapp.pid)
if kill -0 "$PID" 2>/dev/null; then
kill -SIGHUP "$PID"
echo "$(date -Iseconds) Sent SIGHUP to Node.js PID $PID"
fi
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-node.sh
Certbot’s automatic renewal cron job or systemd timer runs twice daily. When the certificate is within 30 days of expiry, Certbot renews it and runs all scripts in /etc/letsencrypt/renewal-hooks/deploy/. The SIGHUP causes your Node.js server to reload the new cert files, all without dropping existing connections.
Step 12: Monitor TLS Errors and Audit Cipher Suites in Production
A TLS server that is running is not necessarily a TLS server that is healthy. Clients may be connecting with rejected protocol versions, expired certificates may appear in the chain, and cipher suite mismatches may cause silent fallbacks. Logging TLS errors lets you catch these issues before they reach customers.
// server-monitoring.mjs
import https from 'node:https';
import fs from 'node:fs';
const CERT_DIR = '/etc/letsencrypt/live/yourdomain.com';
const server = https.createServer(
{
key: fs.readFileSync(`${CERT_DIR}/privkey.pem`),
cert: fs.readFileSync(`${CERT_DIR}/fullchain.pem`),
minVersion: 'TLSv1.2',
},
(req, res) => { res.writeHead(200); res.end('OK\n'); }
);
// TLS-layer errors: protocol version mismatch, bad client cert, etc.
server.on('tlsClientError', (err, tlsSocket) => {
console.error(JSON.stringify({
event: 'tls_client_error',
code: err.code,
msg: err.message,
ip: tlsSocket.remoteAddress,
ts: new Date().toISOString(),
}));
});
// Log negotiated protocol and cipher for each successful TLS session
server.on('secureConnection', (tlsSocket) => {
console.info(JSON.stringify({
event: 'tls_session',
protocol: tlsSocket.getProtocol(),
cipher: tlsSocket.getCipher().name,
ip: tlsSocket.remoteAddress,
ts: new Date().toISOString(),
}));
});
server.listen(443);
The tlsClientError event fires when a client sends a malformed or incompatible TLS handshake. Common sources include TLS 1.0/1.1 clients hitting a TLS 1.2+ server, clients sending unsupported cipher suites, and port scanners probing port 443. Logging the remote IP helps you distinguish automated scanner noise from real client compatibility problems that need a configuration change.
Run an external cipher suite audit periodically with the command-line tool testssl.sh (free, open-source) or the SSL Labs Server Test (free web interface). Both tools flag weak ciphers, misconfigured chains, and missing HSTS headers. Aim for an A+ grade on SSL Labs, which requires TLS 1.3 support, no weak ciphers, HSTS with at least a one-year max-age, and a complete certificate chain.
A spike in tlsClientError events from the same IP range often signals TLS reconnaissance or a misconfigured client library. Feed those IPs into the same rate-limiting system described in the Node.js rate limiting tutorial to block scanners at the IP level before they reach application code.
5 Common Pitfalls That Break TLS 1.3 in Node.js
These are the mistakes that appear most often when developers configure TLS for the first time or migrate from TLS 1.2 to TLS 1.3.
Pitfall 1: Loading cert.pem instead of fullchain.pem. Let’s Encrypt provides three certificate files. cert.pem is the leaf certificate alone. chain.pem is the intermediate chain alone. fullchain.pem concatenates both. Node.js’s cert option requires the full chain. Serving only cert.pem causes UNABLE_TO_VERIFY_LEAF_SIGNATURE in clients that have not cached Let’s Encrypt’s intermediate certificate.
Pitfall 2: Generating a self-signed cert without a Subject Alternative Name. The Common Name (CN) field was deprecated for hostname matching in RFC 6125. Every TLS client since 2017, including Node.js, requires the hostname to appear in the SAN extension. A cert with only CN=localhost and no SAN produces ERR_TLS_CERT_ALTNAME_INVALID. Always include the -addext "subjectAltName=DNS:hostname" flag.
Pitfall 3: Changing minVersion without restarting the process. Node.js reads minVersion at https.createServer() time. Sending a SIGHUP with the reload pattern from Step 11 reloads only the certificate, not protocol settings. TLS version policy changes require a full process restart. Separate these two concerns: keep cert paths in a config file (hot-reloadable) and keep TLS version settings in environment variables (require restart).
Pitfall 4: Setting NODE_TLS_REJECT_UNAUTHORIZED=0 in production. This environment variable disables all certificate verification globally for the entire Node.js process. Developers set it to silence errors during development and then forget to remove it from production configs. Every HTTPS connection your server makes as a client (webhooks, third-party APIs, internal services) becomes vulnerable to man-in-the-middle interception. Fix the underlying certificate issue. For self-signed CAs, use the ca option to trust specific certificates.
Pitfall 5: Assuming TLS 1.3 is negotiated without verifying. Node.js falls back to TLS 1.2 silently if the client does not support TLS 1.3 and you have not set minVersion: 'TLSv1.3'. Logging tlsSocket.getProtocol() on the secureConnection event (Step 12) is the only reliable way to confirm what actually negotiated. In internal microservice communication where Node.js acts as both client and server, check both sides: the client-side code must also set minVersion if you need end-to-end TLS 1.3 enforcement.
Troubleshooting TLS Errors in Node.js
The table below maps the most common TLS error codes to their root causes and fixes. Use it alongside openssl s_client output to diagnose connection problems.
| Error Code | Root Cause | Fix |
|---|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE | Certificate chain is incomplete; cert.pem served instead of fullchain.pem | Switch the cert option to fullchain.pem |
ERR_TLS_CERT_ALTNAME_INVALID | Certificate has no SAN, or SAN does not match the hostname in the request | Regenerate cert with correct subjectAltName extension |
CERT_HAS_EXPIRED | Certificate is past its notAfter date | Run certbot renew --force-renewal and send SIGHUP |
ERR_SSL_PROTOCOL_ERROR | Client and server share no common TLS version | Check minVersion/maxVersion; verify client supports TLS 1.2+ |
SELF_SIGNED_CERT_IN_CHAIN | Client does not trust your CA; common with internal PKI | Pass ca option with your root CA cert, or add it to the system trust store |
ERR_TLS_HANDSHAKE_TIMEOUT | Firewall dropped the connection mid-handshake; port 443 blocked | Verify firewall rules; test reachability with telnet yourdomain.com 443 |
SSL_ERROR_RX_RECORD_TOO_LONG | Client sent plain HTTP to an HTTPS port | Add HTTP-to-HTTPS redirect on port 80 returning 301 to the HTTPS URL |
ERR_OSSL_PEM_NO_START_LINE | Certificate file is corrupted, truncated, or empty | Regenerate the cert file; never append to PEM files with >> |
For errors involving certificate parsing (ERR_OSSL_PEM_BAD_END_LINE), the cert file has been corrupted, often because a script tried to append to an existing file. Regenerate the cert file completely rather than trying to repair it in place.
When debugging TLS problems on a live server, openssl s_client is more informative than browser DevTools because it shows the full handshake transcript and OpenSSL’s error codes directly. Run it from the same network segment as the affected client to rule out intermediate proxy interception or packet manipulation.
Advanced Tips for Production TLS
Use SNICallback for multi-domain servers. If your server hosts multiple domains on the same IP address, TLS SNI lets you present different certificates per hostname. Node.js supports this through the SNICallback option in tls.createServer(), which receives the hostname from the ClientHello and must return a SecureContext object created by tls.createSecureContext() with the appropriate cert and key. Cache the SecureContext objects after the first load to avoid disk reads on every new connection.
Consider a reverse proxy for TLS termination at scale. For high-traffic applications, nginx or Caddy are more efficient at TLS termination than Node.js running directly on port 443. The reverse proxy handles TLS and OCSP stapling, maintains a connection pool to Node.js over plain HTTP on localhost, and benefits from kernel-level optimizations like TCP Fast Open. Node.js then only needs to handle business logic, and its TLS configuration is limited to internal mTLS if you need it.
Enable TLS session tickets for TLS 1.2 fallback performance. For servers that must support TLS 1.2, session tickets allow returning clients to resume their session without a full handshake. Node.js enables session tickets by default. Rotate the session ticket key regularly (every 24 hours for PFS on resumed sessions) by setting ticketKeys to a 48-byte random buffer generated with crypto.randomBytes(48).
Connect TLS monitoring to your OWASP defense stack. A burst of tlsClientError events from the same IP is a strong early signal of TLS scanning or reconnaissance. Feed those events into the same rate-limiting middleware from the Node.js rate limiting tutorial and log them alongside the OWASP Top 10 Node.js defenses for a coherent audit trail.
Plan for post-quantum key exchange now. NIST finalized post-quantum cryptography standards in 2024. OpenSSL 3.5 adds experimental support for X25519Kyber768, a hybrid classical/post-quantum key exchange. While TLS 1.3 itself is not broken by quantum computers, the X25519 key exchange used in today’s connections would be vulnerable to a sufficiently large quantum computer. The post-quantum cryptography landscape for 2026 covers how to monitor this space and when to migrate key exchange algorithms in Node.js.
Related Coverage
These tutorials extend the security stack you built in this guide:
- Node.js Crypto Module: 12 Steps, 30 Min — The foundation for all cryptographic operations in Node.js, from hashing to key generation.
- AES-256 Encryption in Node.js: 12 Steps — Symmetric encryption for data at rest, complementing the transport encryption covered in this tutorial.
- HMAC-SHA256 in Node.js: 10 Steps, 20 Min — Message authentication codes to verify data integrity at the application layer.
- RSA Encryption in Node.js: 11 Steps — Public-key cryptography for key exchange and digital signatures at the application layer.
- OWASP Top 10 in Node.js: 12 Steps to Secure Your API — Broader API security hardening that pairs with TLS transport security.
- Content Security Policy in Node.js: 12 Steps, 30 Min — HTTP security headers including HSTS and CSP for defense in depth.
- Post-Quantum Cryptography: 50% of Web Now Safe — What comes after TLS 1.3 as quantum computers challenge current key exchange algorithms.
FAQ: TLS 1.3 and Node.js HTTPS
Does Node.js support TLS 1.3 out of the box?
Yes. Node.js 12 and later ship with OpenSSL 1.1.1 or higher, which includes TLS 1.3 support. Node.js 22 LTS (current as of June 2026) ships with OpenSSL 3.x and prefers TLS 1.3 when the client supports it. No additional packages are needed: the built-in https and tls modules negotiate TLS 1.3 automatically.
What is the difference between the TLS 1.2 and TLS 1.3 handshake?
TLS 1.2 requires 2 round trips to complete the TLS handshake: one for Hello messages and one for key exchange confirmation. TLS 1.3 reduces this to 1 round trip by sending the client’s key share speculatively in the ClientHello. TLS 1.3 also encrypts the server certificate and removes all non-forward-secret key exchange methods, making the handshake faster, more private, and resistant to downgrade attacks.
How do I check which TLS version my Node.js server is negotiating?
Call tlsSocket.getProtocol() on any TLS socket. In an HTTPS server handler, access it via req.socket.getProtocol(). This returns a string like 'TLSv1.3' or 'TLSv1.2'. From the command line, openssl s_client -connect yourdomain.com:443 -brief <<< "Q" prints the negotiated protocol version in the output.
Is it safe to allow TLS 1.2 alongside TLS 1.3?
Yes, provided you restrict TLS 1.2 to ECDHE cipher suites (no RSA key exchange, no CBC mode). The cipher list in Step 5 achieves that. TLS 1.2 with ECDHE provides Perfect Forward Secrecy and is still considered secure by NIST. The main reason to enforce TLS 1.3-only is compliance requirements or when you know all clients support it, not because TLS 1.2 ECDHE is broken.
What does 0-RTT mean and should I enable it?
0-RTT (early data) is a TLS 1.3 session resumption feature that lets the client include application data in its very first network packet before the handshake completes. This eliminates connection setup latency for repeat visitors. The trade-off is replay vulnerability: a network attacker who records a 0-RTT packet can potentially replay it. For idempotent GET requests and read-only APIs, this risk is generally acceptable. For state-changing requests (POST, payment endpoints, authentication), disable 0-RTT. Node.js does not enable 0-RTT by default.
Why does my Node.js HTTPS server show a certificate warning in Chrome?
Three common causes: (1) You are using a self-signed certificate that is not in your OS trust store. Import it manually or use mkcert, which automatically adds a local CA to macOS Keychain, the Windows Certificate Store, and the Firefox NSS database. (2) The certificate lacks a SAN matching the hostname you are requesting. (3) The certificate has expired. Run openssl x509 -enddate -noout -in cert.pem to check the expiry date and regenerate if needed.
How often does Let’s Encrypt certificate renewal happen?
Let’s Encrypt certificates are valid for 90 days. Certbot’s default renewal threshold is 30 days before expiry, and its systemd timer or cron job checks twice daily. In practice, renewal happens automatically around the 60-day mark. The short validity period is intentional: it limits exposure if a certificate is compromised and forces automation of the renewal process, eliminating the manual-renewal failures common with longer-lived certificates.
What is the difference between the tls and https modules in Node.js?
The tls module provides raw TLS/SSL socket functionality via tls.createServer(), tls.connect(), and tls.createSecureContext(). The https module wraps tls with the HTTP request/response protocol on top, the same way the http module wraps raw TCP sockets. For web servers and REST APIs, use https. For custom TCP protocols that need TLS encryption (database connections, message brokers, proprietary protocols), use tls directly and implement your own framing on top of the socket.




