TLS in Node.js Is a Black Box - Here's an Open One

javascript dev.to

Every Node.js app you've ever built uses TLS. Every https.createServer(), every fetch() to an external API, every database connection with ssl: true — all of it goes through TLS.

But have you ever actually seen what happens during a TLS handshake? Have you ever tried to control which cipher suite is used for a specific connection? Pinned a certificate? Inspected the key exchange? Read the traffic keys?

Probably not. And it's not because you're not curious — it's because Node.js doesn't let you.

Node's node:tls module is a thin wrapper around OpenSSL, a C library with over 700,000 lines of code. It works, it's fast, and for most use cases it's fine. But it's a black box. You call tls.connect(), something happens in C-land, and you either get a secure connection or an error. What happened in between? Which parameters were negotiated? Could you have made different choices? Good luck finding out.

This article is about LemonTLS — a pure JavaScript implementation of TLS 1.3 and TLS 1.2 for Node.js. Not a wrapper. Not a binding. The actual protocol, implemented in JavaScript, where you can read every line, set a breakpoint on every step, and control things that OpenSSL has no API for.


Why Would Anyone Reimplement TLS?

Fair question. OpenSSL works. It's been battle-tested for decades. Why would you want a JavaScript implementation?

Here are three concrete reasons — and they're not theoretical.

1. You Can't Control What You Can't See

Try to do any of these in Node.js:

Pin a certificate. You want to make sure your app only connects to a server presenting a specific certificate — not just any certificate signed by any CA. This is critical for banking apps, internal services, and anything where a compromised CA could be disastrous. In Node.js, you have to implement this manually by checking the peer certificate after the handshake. There's no built-in option.

Choose a cipher suite per connection. Node.js sets cipher suites globally. If you have one connection to a legacy server that needs AES-128 and another to a modern API where you want ChaCha20, you can't configure them independently without openssl.cnf hacks.

Disable session tickets for a specific server. Session tickets are great for performance, but they carry security trade-offs (forward secrecy during the ticket lifetime). In Node.js, disabling them requires modifying OpenSSL's configuration file — not something you can do per-connection.

Detect bots with JA3 fingerprinting. Every TLS client sends a ClientHello with a unique combination of extensions, cipher suites, and curves. This creates a fingerprint (JA3) that can identify bots, scrapers, and known malicious clients. Node.js gives you no access to the raw ClientHello.

With LemonTLS, all of these are one-liner options:

import tls from 'lemon-tls';

// Certificate pinning
tls.connect(443, 'bank.example.com', {
  pins: ['sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=']
});

// Per-connection cipher and curve selection
tls.connect(443, 'api.example.com', {
  allowedCipherSuites: [0x1301, 0x1303],  // AES-128-GCM + ChaCha20 only
  groups: [0x001d],                        // X25519 only
  signatureAlgorithms: [0x0804],           // RSA-PSS-SHA256 only
  prioritizeChaCha: true                   // Prefer ChaCha20 over AES
});

// Disable session tickets
tls.createServer({ key, cert, noTickets: true });

// JA3 fingerprinting
server.on('secureConnection', (socket) => {
  const ja3 = socket.getJA3();
  console.log(ja3.hash);  // 'e7d705a3286e19ea42f587b344ee6865'
  if (knownBots.has(ja3.hash)) socket.destroy();
});
Enter fullscreen mode Exit fullscreen mode

None of these require configuration files, environment variables, or OpenSSL rebuilds. They're just options you pass to a function.

2. You Can Actually Debug TLS Problems

Everyone who's worked with TLS has seen this error:

Error: UNABLE_TO_VERIFY_LEAF_SIGNATURE
Enter fullscreen mode Exit fullscreen mode

Or this one:

Error: self signed certificate in certificate chain
Enter fullscreen mode Exit fullscreen mode

When these happen in Node.js, you're stuck. The error comes from deep inside OpenSSL. You can't set a breakpoint, you can't inspect the certificate chain at the point of failure, you can't see which validation step failed and why.

With LemonTLS, TLS is JavaScript. You can:

  • Set a breakpoint inside the handshake and step through each message
  • Inspect the raw ClientHello and ServerHello
  • Watch key exchange happen in real time
  • See exactly which certificate in the chain failed validation and why
// See every handshake message
socket.on('handshakeMessage', (type, raw, parsed) => {
  console.log('Handshake:', type, parsed);
});

// Wireshark integration — export keys for packet inspection
socket.on('keylog', (line) => {
  fs.appendFileSync('keys.log', line);
});
// Then in Wireshark: TLS → Pre-Master-Secret log filename → keys.log
Enter fullscreen mode Exit fullscreen mode

For development, testing, and debugging — the ability to see what TLS is doing changes everything. Instead of googling cryptic OpenSSL errors, you read JavaScript.

3. Some Protocols Need TLS Internals

This is the reason LemonTLS was originally built. Some protocols need access to TLS internals that OpenSSL simply doesn't expose through Node.js.

QUIC needs the TLS handshake keys at specific points during the connection setup. It needs to encrypt packets with derived keys, not through a socket — because QUIC runs over UDP, not TCP. Node's node:tls gives you a TCP socket and nothing else. You can't extract the keys, you can't encrypt individual records, you can't run TLS without a TCP connection underneath.

LemonTLS solves this with two API levels:

// High-level: drop-in replacement for node:tls (works with TCP)
import tls from 'lemon-tls';
const socket = tls.connect(443, 'example.com');

// Low-level: state machine only — you handle the transport
import { TLSSession } from 'lemon-tls';
const session = new TLSSession({ isServer: false });
session.on('send', (data) => { /* send over UDP, WebSocket, anything */ });
session.on('keys', (keys) => { /* use these for QUIC encryption */ });
session.receive(incomingData);
Enter fullscreen mode Exit fullscreen mode

The TLSSession is a pure state machine. It doesn't know or care what transport you're using. Feed it bytes, it produces handshake messages and keys. This is what makes it possible to build protocols like QUIC, DTLS, or any custom transport that needs TLS security — entirely in JavaScript.


Drop-In Replacement: Same API, More Control

Despite all these advanced capabilities, the basic usage is intentionally boring. LemonTLS implements the same API as node:tls:

import tls from 'lemon-tls';  // just change the import
import fs from 'node:fs';

// Server — same as node:tls
const server = tls.createServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt'),
}, (socket) => {
  console.log('Protocol:', socket.getProtocol());  // 'TLSv1.3'
  console.log('Cipher:', socket.getCipher().name);  // 'TLS_AES_128_GCM_SHA256'
  socket.write('Hello from LemonTLS!\n');
});
server.listen(8443);

// Client — same as node:tls
const socket = tls.connect(8443, 'localhost', {
  rejectUnauthorized: false
}, () => {
  socket.write('Hello from client!\n');
});
socket.on('data', (d) => console.log(d.toString()));
Enter fullscreen mode Exit fullscreen mode

createServer(), connect(), TLSSocket as a Duplex stream, getProtocol(), getCipher(), getPeerCertificate(), alpnProtocol, SNICallback — all the methods and events you know from node:tls work exactly the same way.

The 27 compatibility methods from Node.js have been verified through automated tests. If your code works with node:tls, changing the import to lemon-tls should just work.


Session Resumption: Faster Reconnections

TLS 1.3 introduced a mechanism where, after the first handshake, the server sends the client a "session ticket" — an encrypted blob that lets the client skip the full handshake on the next connection. Instead of exchanging certificates and doing key agreement from scratch, the client presents the ticket and both sides derive keys immediately.

This is especially important for mobile apps and APIs where connections are frequently opened and closed. The difference between a full handshake (1 round-trip) and a resumed handshake (same round-trip, but less computation) adds up.

let savedSession = null;

// First connection — save the ticket
const socket1 = tls.connect(8443, 'example.com');
socket1.on('session', (ticketData) => {
  savedSession = ticketData;
});

// Later — resume with the ticket
const socket2 = tls.connect(8443, 'example.com', {
  session: savedSession
}, () => {
  console.log('Resumed:', socket2.isResumed);  // true
  // No certificate exchange happened — faster connection
});
Enter fullscreen mode Exit fullscreen mode

This works identically to Node.js session resumption, but with one difference: you can actually debug it. If resumption fails, you can inspect the ticket, see the PSK binder validation, and understand exactly why.


Mutual TLS: Both Sides Authenticate

Standard TLS is one-sided — the client verifies the server, but the server doesn't verify the client. For internal services, microservice communication, or zero-trust architectures, you often need both sides to present certificates.

// Server: require client certificate
const server = tls.createServer({
  key: serverKey,
  cert: serverCert,
  requestCert: true,
  ca: [clientCA]  // Only accept clients signed by this CA
});

server.on('secureConnection', (socket) => {
  const peer = socket.getPeerCertificate();
  console.log('Client:', peer.subject.CN);
  console.log('Valid:', socket.authorized);
});

// Client: present certificate
const socket = tls.connect(8443, 'service.internal', {
  cert: fs.readFileSync('client.crt'),
  key: fs.readFileSync('client.key'),
  ca: [serverCA]
});
Enter fullscreen mode Exit fullscreen mode

Same API as node:tls. But if something goes wrong with certificate validation — wrong CA, expired cert, hostname mismatch — you can actually trace through the JavaScript to see where and why it failed.


What's Actually Happening: The TLS 1.3 Handshake

One of the benefits of having TLS in JavaScript is that you can actually understand what the protocol does. Here's what happens when you call tls.connect():

Step 1: ClientHello. The client sends a message listing: supported TLS versions, supported cipher suites, supported key exchange groups, a random nonce, and a key share (the client's half of the key exchange). In TLS 1.3, the client optimistically includes its key share in the first message — this is what makes the handshake only one round-trip.

Step 2: ServerHello. The server picks a cipher suite and key exchange group, includes its own key share, and sends it back. At this point, both sides have everything they need to compute the shared secret.

Step 3: Encrypted handshake. Everything after ServerHello is encrypted. The server sends its certificate, proves it has the private key (via a CertificateVerify message), and sends a Finished message. The client verifies everything, sends its own Finished, and the handshake is complete.

Step 4: Application data. Both sides now have traffic keys derived from the shared secret. All application data is encrypted with AES-GCM or ChaCha20-Poly1305.

The entire handshake happens in one round-trip — the client sends ClientHello, and the server responds with everything needed to start sending encrypted data. Compare this to TLS 1.2, which required two round-trips.

With LemonTLS, you can watch this happen in real time:

socket.on('handshakeMessage', (type, raw, parsed) => {
  console.log(`[${type}]`, JSON.stringify(parsed, null, 2));
});

socket.on('secureConnect', () => {
  console.log(socket.getNegotiationResult());
  // { version: 'TLSv1.3', cipher: 'TLS_AES_128_GCM_SHA256',
  //   group: 'X25519', alpn: 'h2', resumed: false,
  //   handshakeDuration: 23 }
});
Enter fullscreen mode Exit fullscreen mode

The Full Negotiation Picture

After the handshake, you have complete visibility into what was negotiated:

socket.on('secureConnect', () => {
  // What version and cipher were agreed on?
  console.log(socket.getProtocol());        // 'TLSv1.3'
  console.log(socket.getCipher());          // { name: 'TLS_AES_128_GCM_SHA256', ... }

  // What key exchange was used?
  console.log(socket.getEphemeralKeyInfo()); // { type: 'X25519', size: 253 }

  // Who is the server?
  const cert = socket.getPeerCertificate();
  console.log(cert.subject.CN);             // 'example.com'
  console.log(cert.fingerprint256);         // 'AB:CD:EF:...'

  // Was this a resumed connection?
  console.log(socket.isResumed);            // false

  // What ALPN protocol was negotiated?
  console.log(socket.alpnProtocol);         // 'h2'

  // Access the raw shared secret (for research)
  console.log(socket.getSharedSecret());    // Buffer<...>

  // Export keying material (RFC 5705)
  const material = socket.exportKeyingMaterial(32, 'my-protocol', Buffer.alloc(0));
});
Enter fullscreen mode Exit fullscreen mode

Every piece of information that was negotiated, exchanged, or derived during the handshake is available to you. Not hidden in a C struct somewhere — accessible as JavaScript values you can log, inspect, and use.


Zero Dependencies, Full Transparency

LemonTLS uses only node:crypto for the raw cryptographic primitives (AES, SHA-256, ECDH). Everything else — the state machine, the handshake logic, the record layer, the key schedule, the certificate parsing — is JavaScript.

The entire library is readable. When something goes wrong, you don't get an opaque error from a C library — you get a JavaScript stack trace that points to the exact line where the issue occurred.

For security-sensitive applications, auditability matters. You can read the key derivation code, verify the HKDF implementation, check the AEAD encryption. With OpenSSL, you're trusting 700,000 lines of C that even most security researchers don't fully understand.


Getting Started

npm install lemon-tls
Enter fullscreen mode Exit fullscreen mode

For most use cases, change one import:

- import tls from 'node:tls';
+ import tls from 'lemon-tls';
Enter fullscreen mode Exit fullscreen mode

Everything else stays the same — and you gain visibility, control, and debuggability that node:tls can't offer.

Resources:


Why This Matters

TLS is the most important security protocol on the internet. Every HTTPS request, every secure WebSocket, every encrypted database connection depends on it. And yet, for most Node.js developers, it's a complete black box.

It doesn't have to be. TLS is a well-documented protocol with clear RFCs. The only reason it's been opaque is because the implementations have been in C, hidden behind abstraction layers that prioritize convenience over understanding.

LemonTLS gives you both: the convenience of a Node.js-compatible API when you just want things to work, and full protocol-level access when you need to understand, debug, or control what's happening. Same API, open box.

Source: dev.to

arrow_back Back to Tutorials