SSL Certificate Management with Node.js
Transport Layer Security (TLS), still commonly referred to as SSL, is the backbone of secure communication on the modern web. Every HTTPS request, every secure WebSocket, and every encrypted API call relies on an X.509 certificate to prove the identity of the server and to bootstrap an encrypted channel. Node.js ships with first-class support for TLS through its built-in tls and https modules, which means you can build production-grade secure services without pulling in a single third-party dependency.
This article walks through the full lifecycle of certificate management in Node.js: understanding the underlying formats, generating certificates for development, wiring them into an HTTPS server, serving multiple domains from a single process, inspecting certificate metadata programmatically, monitoring expiration, automating issuance and renewal with ACME, and enforcing mutual authentication. By the end you should have a clear mental model of how certificates flow through a Node.js application and how to keep them healthy over time.
Understanding TLS and X.509 Certificates
A TLS certificate is a signed statement that binds a public key to an identity, typically a domain name. The statement is encoded as an X.509 structure and signed by a Certificate Authority (CA). When a client connects, the server presents its certificate along with any intermediate certificates that link it back to a trusted root. The client verifies the signature chain, checks that the certificate has not expired, and confirms that the hostname it dialed matches the certificate's subject or one of its Subject Alternative Names (SANs).
Three pieces of material are involved on the server side. The private key is the secret half of the key pair and must never leave the server. The certificate (sometimes called the leaf or end-entity certificate) is the public, signed document presented to clients. The chain is the ordered list of intermediate certificates needed to connect the leaf to a trusted root. Getting all three right is what most certificate-related bugs come down to.
A frequent source of "certificate works in a browser but not in a script" problems is a missing intermediate certificate. Browsers often cache intermediates from previous sessions, while a fresh Node.js client validating the chain from scratch has no such cache and will reject an incomplete chain.
Certificate Formats and Encodings
Node.js reads certificates and keys in PEM format, a Base64 encoding wrapped in human-readable header and footer lines. A PEM file that begins with -----BEGIN CERTIFICATE----- holds a certificate, while one that begins with -----BEGIN PRIVATE KEY----- holds a key. You may occasionally encounter binary DER files or bundled PKCS#12 archives (.p12 or .pfx), which combine a key and its certificate chain into a single password-protected file. Node.js can consume PKCS#12 directly through the pfx and passphrase options.
The table below summarizes the encodings you are most likely to meet in practice.
| Format | Typical extension | Contents | Node.js option |
|---|---|---|---|
| PEM | .pem, .crt, .key |
Base64 text, one item per block | key, cert, ca |
| DER | .der, .cer |
Binary certificate | Convert to PEM first |
| PKCS#12 | .p12, .pfx |
Key plus chain, encrypted | pfx, passphrase |
You can convert between formats with OpenSSL. Converting a DER certificate to PEM, for example, is a single command.
# Convert a DER-encoded certificate to PEM
openssl x509 -inform der -in certificate.der -out certificate.pem
# Extract the certificate and key from a PKCS#12 archive
openssl pkcs12 -in bundle.pfx -clcerts -nokeys -out certificate.pem
openssl pkcs12 -in bundle.pfx -nocerts -nodes -out private-key.pem
Generating Self-Signed Certificates for Development
During local development you rarely want to involve a real CA. A self-signed certificate lets you exercise the full TLS code path on your machine. The most convenient approach on a workstation is mkcert, which installs a local CA into your system trust store so browsers accept the certificates without warnings. For scripting and CI, plain OpenSSL is perfectly adequate.
# Generate a 2048-bit RSA key and a self-signed certificate valid for 365 days
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout private-key.pem \
-out certificate.pem \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
The subjectAltName extension matters: modern TLS clients, including Node.js itself, ignore the legacy Common Name field and validate the hostname exclusively against the SAN list. A certificate without a matching SAN entry will be rejected even if the Common Name is correct.
You can also generate a key pair and a self-signed certificate entirely inside Node.js using the crypto module, which is handy for tests and ephemeral servers.
const { generateKeyPairSync, X509Certificate } = require('node:crypto');
// Generate an RSA key pair in PEM format
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
console.log(privateKey);
console.log(publicKey);
Creating an HTTPS Server
The https module mirrors the familiar http API, adding a mandatory options object that carries the key and certificate. At minimum you provide key and cert as PEM buffers or strings.
const https = require('node:https');
const fs = require('node:fs');
// Load the private key and certificate from disk once at startup
const options = {
key: fs.readFileSync('./private-key.pem'),
cert: fs.readFileSync('./certificate.pem')
};
const server = https.createServer(options, (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Secure connection established\n');
});
server.listen(443, () => {
console.log('HTTPS server listening on port 443');
});
Reading the certificate files synchronously at startup is deliberate. You want the process to fail loudly and immediately if the material is missing or unreadable, rather than crashing on the first incoming request. Reading them once also avoids touching the filesystem on every connection.
If you use a framework such as Express, you create the application as usual and hand it to https.createServer as the request listener.
const express = require('express');
const https = require('node:https');
const fs = require('node:fs');
const app = express();
app.get('/', (req, res) => {
res.send('Hello over TLS');
});
const options = {
key: fs.readFileSync('./private-key.pem'),
cert: fs.readFileSync('./certificate.pem')
};
https.createServer(options, app).listen(443);
Loading the Full Certificate Chain
Real-world certificates from a public CA arrive with one or more intermediate certificates. If you serve only the leaf, strict clients will refuse to trust it. There are two ways to supply the chain. The first is to concatenate the leaf and intermediates into a single PEM file, ordered from leaf to root, and pass it as cert. Let's Encrypt does this for you in its fullchain.pem output.
const options = {
key: fs.readFileSync('/etc/letsencrypt/live/example.com/privkey.pem'),
// fullchain.pem already contains the leaf followed by the intermediates
cert: fs.readFileSync('/etc/letsencrypt/live/example.com/fullchain.pem')
};
The second approach keeps the leaf in cert and supplies the intermediates separately through the ca option, which accepts either a single PEM or an array of PEMs. This is useful when your CA gives you the chain as distinct files.
const options = {
key: fs.readFileSync('./private-key.pem'),
cert: fs.readFileSync('./leaf.pem'),
// Provide each intermediate as a separate entry
ca: [
fs.readFileSync('./intermediate-1.pem'),
fs.readFileSync('./intermediate-2.pem')
]
};
You can verify that a server presents a complete chain from the command line before trusting it in code.
# Show the certificate chain the server sends and whether verification succeeds
openssl s_client -connect example.com:443 -servername example.com -showcerts
Serving Multiple Domains with SNI
A single Node.js process can terminate TLS for many domains, each with its own certificate, thanks to Server Name Indication. SNI is a TLS extension in which the client announces the hostname it wants during the handshake, allowing the server to select the matching certificate before the connection is fully established. Node.js exposes this through the SNICallback option and the tls.createSecureContext helper.
const https = require('node:https');
const tls = require('node:tls');
const fs = require('node:fs');
// Build one secure context per hostname, keyed by domain name
const contexts = {
'a.example.com': tls.createSecureContext({
key: fs.readFileSync('./a/privkey.pem'),
cert: fs.readFileSync('./a/fullchain.pem')
}),
'b.example.com': tls.createSecureContext({
key: fs.readFileSync('./b/privkey.pem'),
cert: fs.readFileSync('./b/fullchain.pem')
})
};
const options = {
// A default certificate used when the client sends no SNI or an unknown host
key: fs.readFileSync('./default/privkey.pem'),
cert: fs.readFileSync('./default/fullchain.pem'),
SNICallback(servername, callback) {
const context = contexts[servername];
if (context) {
callback(null, context);
} else {
// Fall back to the default context for unknown hosts
callback(null);
}
}
};
https.createServer(options, (req, res) => {
res.end(`Served for host: ${req.headers.host}\n`);
}).listen(443);
Because each secure context is created once and cached in the contexts map, the SNICallback stays cheap even under heavy load. When a certificate is renewed you rebuild only the affected context rather than restarting the server, which we return to in the section on renewal.
Inspecting Certificates Programmatically
Node.js can parse and inspect certificates without shelling out to OpenSSL. The X509Certificate class, available in the crypto module, exposes the subject, issuer, validity window, SANs, fingerprint, and more. This is the foundation for expiration monitoring and diagnostics.
const { X509Certificate } = require('node:crypto');
const fs = require('node:fs');
const pem = fs.readFileSync('./certificate.pem');
const cert = new X509Certificate(pem);
console.log('Subject:', cert.subject);
console.log('Issuer:', cert.issuer);
console.log('Valid from:', cert.validFrom);
console.log('Valid to:', cert.validTo);
console.log('Subject alt names:', cert.subjectAltName);
console.log('SHA-256 fingerprint:', cert.fingerprint256);
You can also read the certificate presented by a remote server after a live handshake. The socket returned by a TLS connection offers getPeerCertificate, which returns a structured object describing the peer's certificate.
const tls = require('node:tls');
const socket = tls.connect({ host: 'example.com', port: 443, servername: 'example.com' }, () => {
const peer = socket.getPeerCertificate();
console.log('Remote subject:', peer.subject);
console.log('Expires on:', peer.valid_to);
socket.end();
});
Monitoring Certificate Expiration
Expired certificates are one of the most common and most avoidable causes of outages. A small monitoring routine that reads the validTo field and warns when a certificate is close to expiry pays for itself many times over. The function below computes the number of days remaining and returns a status you can feed into alerts or a health endpoint.
const { X509Certificate } = require('node:crypto');
const fs = require('node:fs');
const MS_PER_DAY = 1000 * 60 * 60 * 24;
function checkExpiration(path, warnDays = 30) {
const cert = new X509Certificate(fs.readFileSync(path));
const expires = new Date(cert.validTo);
const daysLeft = Math.floor((expires.getTime() - Date.now()) / MS_PER_DAY);
let status = 'ok';
if (daysLeft < 0) {
status = 'expired';
} else if (daysLeft <= warnDays) {
status = 'expiring';
}
return { status, daysLeft, expiresAt: expires.toISOString() };
}
const report = checkExpiration('./certificate.pem');
if (report.status !== 'ok') {
console.warn(`Certificate ${report.status}: ${report.daysLeft} days left`);
}
In production you would run this check on a schedule, for example once a day, and forward any non-ok status to your alerting system. Exposing the same result through an unauthenticated /healthz endpoint lets external monitors track certificate health without direct filesystem access.
Automating Issuance and Renewal with ACME
Public certificates from Let's Encrypt and similar authorities are issued through the ACME protocol and are valid for only ninety days, which makes automation essential. You have two broad options. The operationally simplest is to let a dedicated tool such as Certbot or acme.sh obtain and renew certificates on disk, while your Node.js process merely reads the resulting fullchain.pem and privkey.pem. This separation of concerns is robust and keeps ACME logic out of your application.
When you prefer to keep issuance inside the Node.js process, the acme-client library speaks ACME directly. The sketch below shows the shape of an HTTP-01 challenge flow, where the CA verifies domain control by fetching a token from a well-known URL on your server.
const acme = require('acme-client');
async function obtainCertificate(domain, email) {
// Create an ACME account key and a client bound to the CA directory
const accountKey = await acme.crypto.createPrivateKey();
const client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production,
accountKey
});
// Generate a CSR and the matching private key for the domain
const [key, csr] = await acme.crypto.createCsr({
commonName: domain
});
// Drive the order to completion, answering the HTTP-01 challenge
const cert = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
async challengeCreateFn(authz, challenge, keyAuthorization) {
// Publish keyAuthorization at /.well-known/acme-challenge/<token>
},
async challengeRemoveFn(authz, challenge, keyAuthorization) {
// Remove the published token once the challenge is validated
}
});
return { key, cert };
}
Whichever path you choose, the application still needs to pick up renewed material without downtime. The cleanest technique is to rebuild the secure context when the certificate files change and swap it in atomically. With the SNI setup shown earlier, a file watcher can refresh a single context in place.
const tls = require('node:tls');
const fs = require('node:fs');
function loadContext(domain) {
return tls.createSecureContext({
key: fs.readFileSync(`/etc/letsencrypt/live/${domain}/privkey.pem`),
cert: fs.readFileSync(`/etc/letsencrypt/live/${domain}/fullchain.pem`)
});
}
const contexts = new Map();
contexts.set('example.com', loadContext('example.com'));
// Reload the context whenever the live certificate changes on disk
fs.watch('/etc/letsencrypt/live/example.com/fullchain.pem', () => {
try {
contexts.set('example.com', loadContext('example.com'));
console.log('Reloaded certificate for example.com');
} catch (err) {
// Keep serving the previous context if the reload fails
console.error('Reload failed:', err.message);
}
});
Note the defensive try/catch: if a renewal writes files in a non-atomic way, a watcher can briefly observe a truncated file. Keeping the previous working context on failure avoids serving a broken certificate during that window.
Mutual TLS and Client Certificate Authentication
So far the client has verified the server. Mutual TLS (mTLS) turns this around so the server also verifies the client, using client certificates as a strong authentication mechanism for service-to-service traffic. You enable it by setting requestCert to true, providing the CA that signs your client certificates through ca, and setting rejectUnauthorized to reject any client that fails validation.
const https = require('node:https');
const fs = require('node:fs');
const options = {
key: fs.readFileSync('./server-key.pem'),
cert: fs.readFileSync('./server-cert.pem'),
// The CA that issued the client certificates we are willing to trust
ca: fs.readFileSync('./client-ca.pem'),
requestCert: true,
rejectUnauthorized: true
};
https.createServer(options, (req, res) => {
const cert = req.socket.getPeerCertificate();
// A verified client certificate always carries a subject
if (cert && cert.subject) {
res.end(`Authenticated as ${cert.subject.CN}\n`);
} else {
res.writeHead(401).end('Client certificate required\n');
}
}).listen(8443);
On the client side, a Node.js process authenticates by presenting its own certificate and key when connecting.
const https = require('node:https');
const fs = require('node:fs');
const options = {
hostname: 'example.com',
port: 8443,
path: '/',
method: 'GET',
key: fs.readFileSync('./client-key.pem'),
cert: fs.readFileSync('./client-cert.pem'),
ca: fs.readFileSync('./server-ca.pem')
};
const req = https.request(options, (res) => {
res.on('data', (chunk) => process.stdout.write(chunk));
});
req.end();
Best Practices
A few habits keep certificate management calm rather than chaotic. Store private keys with restrictive filesystem permissions, readable only by the user that runs the Node.js process, and never commit them to version control. Prefer serving the full chain rather than the bare leaf so that any client, cached or not, can validate you. Automate renewal from day one and pair it with an independent expiration monitor, because renewal tooling can silently fail and the monitor is your safety net.
Keep TLS termination decisions explicit. Terminating TLS directly in Node.js is fine for small services and internal traffic, but at scale a reverse proxy such as Nginx or a load balancer often handles certificates more efficiently and centralizes renewal. When you do terminate in Node.js, pin a sensible minimum protocol version and let the runtime pick modern cipher suites rather than hand-crafting a list that ages badly.
const options = {
key: fs.readFileSync('./private-key.pem'),
cert: fs.readFileSync('./fullchain.pem'),
// Refuse anything older than TLS 1.2
minVersion: 'TLSv1.2'
};
Finally, treat certificate rotation as a routine event rather than an emergency. If your deployment can swap certificates through a file watcher or a graceful reload without dropping connections, an expiring certificate becomes a non-event. The combination of automated issuance, in-process hot reload, and proactive monitoring is what separates a secure service that quietly renews itself from one that pages an engineer every ninety days.
Conclusion
Node.js gives you everything needed to manage TLS certificates end to end: the https module to terminate connections, the tls module for SNI and fine-grained control, and the crypto module's X509Certificate class to inspect and monitor certificates. Building on these primitives, you can generate development certificates, serve many domains from one process, enforce mutual authentication, automate issuance through ACME, and hot-reload renewed material without downtime. Master these building blocks and certificate management shifts from a recurring source of outages to a well-understood, automated part of your infrastructure.