Skip to content

HTTPS Module - Wrapping HTTP in TLS

Table of Contents


The TLS Layer

HTTPS is HTTP running over TLS (formerly SSL). Node's https module wraps http with TLS encryption using the tls and crypto core modules. Without valid certificates , the TLS handshake fails and your clients get ERR_CERT_AUTHORITY_INVALID or just a refused connection

Data sent over plain HTTP is visible to every router , ISP , and coffee shop attacker between client and server. HTTPS ensures confidentiality (encryption), integrity (no tampering), and authentication (server is who it claims)

https.createServer with Certificates

const https = require('node:https')
const fs = require('node:fs')
const path = require('node:path')

const options = {
  key: fs.readFileSync(path.join(__dirname, 'key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
}

const server = https.createServer(options, (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({
    secure: req.socket.encrypted,
    protocol: req.socket.getProtocol() // 'TLSv1.3'
  }))
})

server.listen(443, () => {
  console.log('HTTPS server listening on port 443')
})

The options object is required for HTTPS - without key and cert, createServer throws ERR_INVALID_ARG_TYPE

Key options: * key - private key (PEM string or Buffer) * cert - certificate chain (PEM string or Buffer) * ca - override default CA bundle for custom certs * passphrase - if private key is encrypted * pfx - alternative to key+cert for PFX/P12 files

Generating Self-Signed Certs with OpenSSL

For development and testing , you dont need a CA - self-signed certs work fine (your browser will just scream "unsafe" at you)

# generate a 2048-bit RSA private key
openssl genrsa -out key.pem 2048

# create a certificate signing request
openssl req -new -key key.pem -out csr.pem -subj "/CN=localhost"

# self-sign the cert (valid for 365 days)
openssl x509 -req -in csr.pem -signkey key.pem -out cert.pem -days 365

# or do it all in one command:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 \
  -nodes -subj "/CN=localhost"

The -nodes flag means "no DES" - your private key wont be encrypted with a passphrase. Remove -nodes if you want passphrase protection (your server will prompt for it at startup)

For multiple domains or 127.0.0.1 access:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 \
  -nodes -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,DNS:*.local,IP:127.0.0.1"

HTTP to HTTPS Redirect Pattern

You never want HTTP traffic landing on your application logic. The pattern is an HTTP server that issues 301 redirects:

const http = require('node:http')
const https = require('node:https')
const fs = require('node:fs')

const httpsOptions = {
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')
}

// HTTP server - only redirects to HTTPS
http.createServer((req, res) => {
  const host = req.headers.host || 'localhost'
  res.writeHead(301, { 'Location': `https://${host}${req.url}` })
  res.end()
}).listen(80)

// HTTPS server - actual application
https.createServer(httpsOptions, (req, res) => {
  res.writeHead(200)
  res.end('You made it to the secure side')
}).listen(443)

Why 301 and not 302: 301 is permanent - browsers cache it , search engines update their indexes. 301 tells the world "I never want to speak HTTP again"

HSTS header - tell browsers to never even try HTTP:

res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')

TLS Versions and Cipher Configuration

Node.js lets you lock down which TLS versions and ciphers your server accepts

const options = {
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
  // minimum TLS version - reject SSLv3 , TLS 1.0 , TLS 1.1
  minVersion: 'TLSv1.2',
  // maximum TLS version
  maxVersion: 'TLSv1.3',
  // specific cipher suite (OpenSSL format)
  ciphers: [
    'TLS_AES_256_GCM_SHA384',    // TLS 1.3
    'TLS_CHACHA20_POLY1305_SHA256', // TLS 1.3
    'ECDHE-RSA-AES128-GCM-SHA256', // TLS 1.2
    'ECDHE-ECDSA-AES256-GCM-SHA384', // TLS 1.2
  ].join(':'),
  // prefer server cipher order over client
  honorCipherOrder: true,
  // require perfect forward secrecy ciphers only
  ecdhCurve: 'auto'
}

What you actually want in production: * minVersion: 'TLSv1.2' - TLS 1.0 and 1.1 are deprecated everywhere * maxVersion: 'TLSv1.3' - TLS 1.3 is the gold standard * honorCipherOrder: true - server picks , not the client

Check your TLS config with openssl s_client:

openssl s_client -connect localhost:443 -tls1_2
openssl s_client -connect localhost:443 -tls1_3

Let's Encrypt and Certbot for Production

Self-signed certs are fine for local dev. For production you need a CA-signed cert and Let's Encrypt gives them for free

# install certbot (Ubuntu/Debian)
sudo apt install certbot

# get certificate (standalone mode - stops your server temporarily)
sudo certbot certonly --standalone -d api.yourdomain.com

# or use webroot mode (no downtime)
sudo certbot certonly --webroot -w /var/www/html -d api.yourdomain.com

Certificates land in /etc/letsencrypt/live/api.yourdomain.com/

const options = {
  key: fs.readFileSync('/etc/letsencrypt/live/api.yourdomain.com/privkey.pem'),
  cert: fs.readFileSync('/etc/letsencrypt/live/api.yourdomain.com/fullchain.pem')
}

https.createServer(options, handler).listen(443)

Auto-renewal with cron:

# run daily at 3 AM
0 3 * * * /usr/bin/certbot renew --quiet --post-hook "systemctl reload nginx"

Certbot certs expire in 90 days. Renewal is your problem - set up monitoring or your site goes dark

Mutual TLS (mTLS) Basics

mTLS flips the authentication - the client also presents a certificate to prove their identity

const options = {
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem'),
  ca: fs.readFileSync('ca-cert.pem'), // CA that signed client certs
  requestCert: true,                    // ask client for certificate
  rejectUnauthorized: true              // reject if cert not signed by ca
}

const server = https.createServer(options, (req, res) => {
  const clientCert = req.socket.getPeerCertificate()

  if (!clientCert.subject) {
    res.writeHead(401)
    return res.end('{"error": "certificate required"}')
  }

  // client identity from certificate
  const clientCN = clientCert.subject.CN
  console.log(`Authenticated client: ${clientCN}`)

  res.writeHead(200)
  res.end(JSON.stringify({ authenticated: clientCN }))
})

mTLS use cases: * Service-to-service auth in microservices * API access for trusted clients without passwords * IoT device authentication * Zero-trust network architectures

Security : TLS 1.3 , Cipher Selection , Perfect Forward Secrecy

Your TLS config is your first line of defense. Screw it up and all the application security in the world wont save you

TLS 1.3 dropped in 2018 and it's a massive improvement: * Removed all insecure ciphers (no more RC4 , 3DES , CBC modes) * Reduced handshake from 2-RTT to 1-RTT (0-RTT for resuming) * Removed static RSA key exchange - every connection gets ephemeral keys * Encrypted more of the handshake (hides certificates from eavesdroppers)

Perfect Forward Secrecy (PFS) means compromising the server's long-term private key doesnt let you decrypt past recorded traffic. Each session generates ephemeral keys through Diffie-Hellman key exchange - those keys are discarded after the session ends

Ciphers that provide PFS start with ECDHE or DHE: * ECDHE-RSA-AES256-GCM-SHA384 (PFS) * ECDHE-ECDSA-AES256-GCM-SHA384 (PFS) * TLS_AES_256_GCM_SHA384 (TLS 1.3 , always PFS)

Ciphers without PFS (avoid): * RSA-AES256-GCM-SHA384 - no forward secrecy * Any TLS_RSA_* - compromised key means all past traffic decrypted

Testing your TLS config:

# check supported protocols
nmap --script ssl-enum-ciphers -p 443 localhost

# detailed handshake analysis
openssl s_client -connect localhost:443 -debug

# comprehensive check (requires testssl.sh)
./testssl.sh --parallel https://localhost:443

Minimum secure config for 2026:

const options = {
  key: privateKey,
  cert: certificate,
  minVersion: 'TLSv1.2',
  maxVersion: 'TLSv1.3',
  ciphers: 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES128-GCM-SHA256',
  honorCipherOrder: true,
  ecdhCurve: 'auto'
}

prerequisites

web_01_http.md - HTTP server fundamentals , request/response objects

next => web_03_routing.md