HTTPS and TLS: Secure Communication

HTTPS isn't optional anymore. Google Chrome marks HTTP sites as 'Not Secure,' search rankings penalize unencrypted traffic, and modern web APIs like geolocation and service workers simply refuse to...

Key Insights

  • TLS 1.3 reduces handshake latency by 50% compared to TLS 1.2, but proper cipher suite configuration matters more than version numbers for real-world security
  • Self-signed certificates work fine for development, but production requires automated renewal strategies—Let’s Encrypt certificates expire every 90 days by design
  • The rejectUnauthorized: false option disables certificate validation entirely and should never exist in production code, yet it appears in 40% of Node.js projects according to GitHub code searches

Introduction to HTTPS and TLS

HTTPS isn’t optional anymore. Google Chrome marks HTTP sites as “Not Secure,” search rankings penalize unencrypted traffic, and modern web APIs like geolocation and service workers simply refuse to work without it.

Transport Layer Security (TLS) is the cryptographic protocol that makes HTTPS possible. It replaced SSL (Secure Sockets Layer) in 1999, though people still incorrectly say “SSL certificate.” TLS provides three critical guarantees: encryption (nobody can read your data), integrity (nobody can modify it in transit), and authentication (you’re actually talking to the server you think you are).

The difference matters for JavaScript developers because you’re not just consuming HTTPS—you’re implementing it in Node.js servers, making client requests, and debugging certificate issues at 2 AM when your API suddenly stops working.

How TLS Handshake Works

The TLS handshake happens before any HTTP data transfers. The client and server negotiate encryption parameters, exchange certificates, and establish session keys. Understanding this process helps you debug connection failures and optimize performance.

Here’s what happens in TLS 1.3 (the simplified version):

  1. Client sends supported cipher suites and key share
  2. Server responds with chosen cipher suite, certificate, and its key share
  3. Both derive session keys from the shared secret
  4. Encrypted communication begins

You can observe this in Node.js:

const tls = require('tls');
const fs = require('fs');

const options = {
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem')
};

const server = tls.createServer(options, (socket) => {
  console.log('Secure connection established');
  console.log('Protocol:', socket.getProtocol());
  console.log('Cipher:', socket.getCipher());
  
  socket.on('data', (data) => {
    socket.write(data); // Echo back
  });
});

server.on('secureConnection', (socket) => {
  const cert = socket.getPeerCertificate();
  console.log('Client certificate subject:', cert.subject);
});

server.listen(8443, () => {
  console.log('TLS server listening on port 8443');
});

The cipher object reveals what encryption algorithms were negotiated. In production, you’ll see something like TLS_AES_256_GCM_SHA384 for TLS 1.3 or ECDHE-RSA-AES128-GCM-SHA256 for TLS 1.2.

Implementing HTTPS in Node.js

Creating an HTTPS server requires a certificate and private key. For development, generate a self-signed certificate:

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

The -nodes flag means “no DES”—it skips password protection on the private key, which is fine for local development but terrible for production.

Here’s a basic HTTPS server:

const https = require('https');
const fs = require('fs');

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

const server = https.createServer(options, (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Secure response\n');
});

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

For production, use Let’s Encrypt with automated renewal. The greenlock-express package handles ACME challenges and certificate management:

const greenlockExpress = require('greenlock-express');

greenlockExpress.init({
  packageRoot: __dirname,
  configDir: './greenlock.d',
  maintainerEmail: 'admin@example.com',
  cluster: false
}).ready((glx) => {
  const app = require('./app.js');
  
  glx.serveApp(app);
});

This automatically obtains certificates, renews them before expiration, and handles HTTP-to-HTTPS redirects.

Making Secure Requests from JavaScript

Node.js provides the https module for outbound requests:

const https = require('https');

https.get('https://api.example.com/data', (res) => {
  let data = '';
  
  res.on('data', (chunk) => {
    data += chunk;
  });
  
  res.on('end', () => {
    console.log(JSON.parse(data));
  });
}).on('error', (err) => {
  console.error('Request failed:', err.message);
});

Certificate validation happens automatically. To use a custom CA certificate (for internal APIs):

const https = require('https');
const fs = require('fs');

const options = {
  hostname: 'internal-api.company.local',
  port: 443,
  path: '/data',
  method: 'GET',
  ca: fs.readFileSync('company-ca.pem')
};

const req = https.request(options, (res) => {
  console.log('Status:', res.statusCode);
  res.on('data', (d) => {
    process.stdout.write(d);
  });
});

req.on('error', (e) => {
  console.error(e);
});

req.end();

In browsers, the Fetch API handles HTTPS transparently:

fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ key: 'value' })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Fetch failed:', error));

Browsers enforce certificate validation strictly. You can’t disable it from JavaScript, which is a security feature, not a limitation.

Common Security Pitfalls

The most dangerous line in Node.js:

// NEVER DO THIS IN PRODUCTION
const https = require('https');

https.get('https://untrusted-site.com', {
  rejectUnauthorized: false  // Disables ALL certificate validation
}, (res) => {
  // This connection is not secure
});

Setting rejectUnauthorized: false defeats the entire purpose of HTTPS. It’s equivalent to ignoring browser security warnings. If you need this for development, use environment-based configuration:

const options = {
  hostname: 'api.example.com',
  rejectUnauthorized: process.env.NODE_ENV === 'production'
};

Better yet, add your development CA to Node.js’s trusted certificates:

export NODE_EXTRA_CA_CERTS=/path/to/dev-ca.pem

Mixed content warnings occur when HTTPS pages load HTTP resources. Browsers block mixed active content (scripts, stylesheets) but only warn about mixed passive content (images). Fix it by using protocol-relative URLs or HTTPS everywhere:

// Bad
const apiUrl = 'http://api.example.com';

// Good
const apiUrl = 'https://api.example.com';

// Also good (inherits page protocol)
const apiUrl = '//api.example.com';

Weak cipher suites remain a problem. Configure your server to reject outdated encryption:

const options = {
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
  ciphers: 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
  minVersion: 'TLSv1.3'
};

Best Practices and Modern Standards

TLS 1.3 eliminates a round trip from the handshake and removes vulnerable cipher suites. Enable it explicitly:

const server = https.createServer({
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
  minVersion: 'TLSv1.3',
  maxVersion: 'TLSv1.3'
}, app);

HSTS (HTTP Strict Transport Security) tells browsers to only use HTTPS for your domain:

const express = require('express');
const app = express();

app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains; preload'
  );
  next();
});

The preload directive lets you submit your domain to browser HSTS preload lists, preventing the first HTTP request entirely.

Monitor certificate expiration to avoid outages:

const https = require('https');
const tls = require('tls');

function checkCertExpiration(hostname) {
  const socket = tls.connect(443, hostname, () => {
    const cert = socket.getPeerCertificate();
    const daysUntilExpiry = Math.floor(
      (new Date(cert.valid_to) - new Date()) / (1000 * 60 * 60 * 24)
    );
    
    console.log(`Certificate expires in ${daysUntilExpiry} days`);
    
    if (daysUntilExpiry < 30) {
      console.warn('Certificate expiring soon!');
    }
    
    socket.end();
  });
}

checkCertExpiration('api.example.com');

Debugging HTTPS Issues

Common errors and their solutions:

UNABLE_TO_VERIFY_LEAF_SIGNATURE: The certificate chain is incomplete. The server needs to send intermediate certificates, not just the leaf certificate.

CERT_HAS_EXPIRED: Self-explanatory. Check certificate dates:

const tls = require('tls');

const socket = tls.connect(443, 'example.com', () => {
  const cert = socket.getPeerCertificate();
  console.log('Valid from:', cert.valid_from);
  console.log('Valid to:', cert.valid_to);
  socket.end();
});

DEPTH_ZERO_SELF_SIGNED_CERT: You’re using a self-signed certificate without adding it to trusted CAs.

Inspect certificates using OpenSSL:

openssl s_client -connect example.com:443 -showcerts

In Node.js, examine all certificate details:

const https = require('https');

const req = https.request('https://example.com', (res) => {
  const cert = res.socket.getPeerCertificate(true);
  
  console.log('Subject:', cert.subject);
  console.log('Issuer:', cert.issuer);
  console.log('Valid from:', cert.valid_from);
  console.log('Valid to:', cert.valid_to);
  console.log('Fingerprint:', cert.fingerprint);
  console.log('Serial number:', cert.serialNumber);
});

req.on('error', (err) => {
  console.error('TLS Error:', err.code, err.message);
});

req.end();

HTTPS isn’t just a checkbox—it’s a complex system with real performance and security implications. Treat certificates as credentials, automate renewal, and never disable validation in production. Your users’ data depends on getting this right.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.