Node.js’s built-in tls module can secure your network communications, but understanding its nuances, especially around certificate pinning, is crucial for robust security.

Let’s see how Node.js handles TLS connections and certificates in action.

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

// Server configuration
const serverOptions = {
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt'),
  ca: fs.readFileSync('ca.crt'), // Optional: if using a CA
  requestCert: true, // Request client certificate
  rejectUnauthorized: true // Reject clients without valid certificates
};

const server = tls.createServer(serverOptions, (cleartextStream) => {
  console.log('Client connected with TLS');
  cleartextStream.on('data', (data) => {
    console.log(`Received from client: ${data}`);
    cleartextStream.write('Hello from server!');
  });
  cleartextStream.on('end', () => {
    console.log('Client disconnected');
  });
});

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

// Client configuration
const clientOptions = {
  ca: fs.readFileSync('ca.crt'), // Trust the CA
  checkServerIdentity: (hostname, cert) => {
    // Certificate pinning logic will go here
    // For now, we'll just log the server's certificate details
    console.log(`Server certificate details: ${cert.subject.CN}`);
    return undefined; // Indicate no error if valid
  }
};

const socket = tls.connect(8000, 'localhost', clientOptions, () => {
  console.log('TLS client connected');
  socket.write('Hello from client!');
});

socket.on('data', (data) => {
  console.log(`Received from server: ${data}`);
});

socket.on('end', () => {
  console.log('TLS connection ended');
});

socket.on('error', (err) => {
  console.error('Client socket error:', err);
});

This code sets up a basic TLS server and client. The server uses its private key and certificate to authenticate itself to clients. The client, in turn, uses the CA certificate to verify the server’s certificate and optionally implements checkServerIdentity for certificate pinning.

The primary problem TLS solves is confidentiality and integrity of data in transit. Without TLS, any data sent over a network is plain text, vulnerable to eavesdropping and modification. TLS encrypts this data and provides mechanisms to verify the identity of the communicating parties, ensuring that you’re talking to who you think you’re talking to, and that the messages haven’t been tampered with.

Internally, Node.js’s tls module leverages the OpenSSL library. When a TLS connection is established, a handshake occurs:

  1. Client Hello: The client initiates by sending supported TLS versions, cipher suites, and other parameters.
  2. Server Hello: The server responds with its chosen TLS version, cipher suite, and sends its certificate.
  3. Certificate Verification: The client verifies the server’s certificate against its trusted CAs.
  4. Key Exchange: The client and server securely exchange keys to establish a symmetric encryption key for the session.
  5. Finished: Both sides send a "finished" message, encrypted with the new session key, to confirm the handshake was successful.

Once the handshake is complete, all subsequent data is encrypted and decrypted using the established session key.

The tls module provides several key options for configuring TLS:

  • key: The server’s private key.
  • cert: The server’s public certificate.
  • ca: A certificate authority’s certificate, used by clients to verify server certificates and by servers to verify client certificates.
  • requestCert: (Server-side) Whether to request a certificate from the client.
  • rejectUnauthorized: (Server-side) Whether to reject clients that don’t present a valid certificate or whose certificate cannot be verified against the provided ca.
  • checkServerIdentity: (Client-side) A function to perform custom server certificate validation, most commonly used for certificate pinning.

Certificate pinning is an advanced security measure. Instead of solely relying on a chain of trust from a public Certificate Authority (CA), you explicitly trust a specific certificate or public key. This prevents a Man-in-the-Middle (MITM) attack where an attacker might compromise a CA or obtain a fraudulent certificate from a rogue CA.

Here’s how you’d implement certificate pinning in the clientOptions using the checkServerIdentity function:

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

// ... (server setup as before) ...

// Client configuration with certificate pinning
const clientOptions = {
  ca: fs.readFileSync('ca.crt'), // Still good practice to have a CA for general trust
  checkServerIdentity: (hostname, cert) => {
    const expectedPublicKey = fs.readFileSync('pinned-public.key'); // The public key you want to pin
    const serverPublicKey = cert.pubkey; // The public key from the server's certificate

    // Compare the public keys. Use a secure comparison method.
    if (!crypto.timingSafeEqual(serverPublicKey, expectedPublicKey)) {
      // If the public keys don't match, throw an error.
      // This error will be caught by socket.on('error')
      throw new Error(`Certificate pinning failed for ${hostname}. Public key mismatch.`);
    }
    console.log(`Certificate pinning successful for ${hostname}.`);
    return undefined; // Indicate success
  }
};

// ... (client connection as before) ...

In this pinned example, pinned-public.key would contain the raw public key bytes of the server’s certificate that you explicitly trust. The crypto.timingSafeEqual function is crucial here; it prevents timing attacks by comparing the keys in constant time, regardless of where the mismatch occurs.

The one thing most people don’t know is that checkServerIdentity can also be used to validate the hostname against the certificate’s Subject Alternative Name (SAN) or Common Name (CN) after you’ve performed your custom pin check. If you return undefined from checkServerIdentity after a successful pin, Node.js then proceeds with its default hostname verification. If you want to override hostname verification entirely, you would return an error string from checkServerIdentity with the hostname mismatch.

The next concept you’ll likely encounter is managing certificate expiration and rotation when using certificate pinning, as updating pinned certificates requires client-side code changes.

Want structured learning?

Take the full Nodejs course →