Get E-Book
TLS, HTTPS & HTTP/2

TLS Handshake and Cipher Negotiation

Ishtmeet Singh @ishtms/June 10, 2026/45 min read
#nodejs#tls#https#openssl#networking

A connected TCP socket already has a local address, a remote address, kernel buffers, and stream state. It can send and receive bytes. TCP does not assign any meaning to those bytes. Whatever protocol runs on top decides that.

TLS runs on top of that ordered TCP byte stream and encrypts it. The client and server agree on TLS settings, build shared key material, verify the handshake, then exchange application data inside TLS records. HTTP can run above that encrypted stream. So can a database protocol, or a binary protocol you wrote yourself. TLS works the same way for all of them, because it never parses the protocol above it. The one thing it requires from below is a transport that delivers bytes in order.

Node gives you this through the node:tls module, which sits on top of OpenSSL. Your JavaScript creates sockets, passes options, listens for events, and reads decrypted bytes. Everything in the TLS protocol itself is OpenSSL's work - building and parsing handshake messages, negotiating the version, selecting a cipher, deriving keys, protecting records, handling alerts, and reusing sessions.

The client side is short -

js
import tls from 'node:tls';

const socket = tls.connect({
  host: 'nodejs.org',
  port: 443,
  servername: 'nodejs.org',
}, () => {
  console.log(socket.getProtocol(), socket.getCipher());
  socket.end();
});

That single call does a lot of work in order. Node first runs the normal outbound networking path from Chapter 9 - name lookup when needed, address selection, TCP connect, and a connected socket. Once TCP is connected, Node attaches TLS client state to that byte stream and tells OpenSSL to start the handshake.

The callback runs only after the TLS client reaches its secure state. That callback is the secureConnect event.

Raw tls.connect() clients should set servername when connecting by hostname. That value goes into SNI during the client hello. Many public TLS servers use SNI before choosing the certificate and TLS policy for the connection. Core HTTPS clients fill this from the URL host. Raw TLS code needs to set it directly.

No HTTP has happened yet, and none can until TLS is ready. A request line like GET / HTTP/1.1 lives above TLS. You can write it to a tls.TLSSocket right away, but OpenSSL wraps it as encrypted TLS application data before any of those bytes reach the TCP socket underneath.

Where the TLS Socket Sits

Most TLS code in Node touches only three names. tls.connect() opens a client connection, tls.createServer() builds a server, and tls.TLSSocket is the encrypted stream you read from and write to.

A tls.TLSSocket behaves like any other Node stream, with one addition. Reads give you decrypted bytes and writes take plaintext, while Node and OpenSSL handle the encrypted form underneath.

When application code writes a Buffer or string, Node sends that plaintext into the TLS layer. OpenSSL turns the plaintext into one or more encrypted TLS records. Node then writes those encrypted bytes to the lower TCP socket.

Reading reverses that. Encrypted bytes arrive from TCP, OpenSSL processes the TLS records, and if the integrity checks pass, Node hands JavaScript the decrypted bytes.

Two views of the same connection -

text
JavaScript sees -
  HTTP bytes, protocol bytes, app payloads

TCP carries -
  TLS records containing protected fragments

Backpressure works the same way it always does. A write can wait in JavaScript stream state, in Node's native TLS code, inside OpenSSL, or in kernel send buffers. A read can stall in several of those places too. TLS adds record packaging and CPU work on top of TCP, and that is all it adds. The transport beneath is the same connected socket you started with.

On the client side, tls.connect() either creates a socket or wraps one you give it, sets up TLS state, starts the handshake, and returns a tls.TLSSocket right away. That object can emit an error before secureConnect ever fires. It can also report stream and address details, because the socket underneath already exists or is being opened. Only the secure state comes later.

Servers run the same sequence in reverse. tls.createServer() accepts TCP connections, runs the server side of the handshake, then hands your code a secure socket -

js
import tls from 'node:tls';
import { readFileSync } from 'node:fs';

const server = tls.createServer({
  key: readFileSync('server-key.pem'),
  cert: readFileSync('server-cert.pem'),
}, socket => {
  socket.end('ok\n');
});

server.listen(8443);

The key and cert fields are the server's identity material, and certificate structure and validation come later. The detail to focus on right now is timing. The callback fires with a tls.TLSSocket only after the server has accepted a TCP connection and done enough TLS work to start handing decrypted application bytes to JavaScript.

Node fires a lower-level event before that one -

js
server.on('connection', socket => {
  console.log(socket.remoteAddress);
});

That event is the raw TCP accept. The peer has connected, but TLS still has work to do. Application protocol code should wait for the secure events instead.

The matching TLS event on the server is secureConnection -

js
server.on('secureConnection', socket => {
  console.log(socket.getProtocol());
  console.log(socket.getCipher().standardName);
});

By the time that fires, OpenSSL has already chosen a TLS version and cipher state for the connection. tlsSocket.getProtocol() returns the negotiated version, usually TLSv1.3 or TLSv1.2. tlsSocket.getCipher() returns the selected cipher suite. Both read back a decision OpenSSL already made during the handshake, so reading them never changes the cipher.

The public object is only the top of the stack. The real work runs through JavaScript, Node's native bindings, OpenSSL, libuv, and the operating system. The internal names can change between Node releases, but which layer holds what stays steady enough to debug against -

text
JavaScript tls.TLSSocket
  -> Node native TLS wrapper
  -> OpenSSL SSL connection state
  -> lower TCP stream
  -> OS socket

The native TLS wrapper lines up stream reads and writes with OpenSSL. OpenSSL holds the per-connection SSL object, which carries the selected version, handshake state, transcript hashes, negotiated secrets, record sequence numbers, cipher state, pending alerts, and session data. Below that, libuv and the operating system handle socket readiness and the actual byte movement.

Stacked diagram of TLS socket layers from JavaScript down to the OS socket

Figure 11.1 - The layers a byte passes through, from the tls.TLSSocket your code reads down to the OS socket. Writes move down and get encrypted, reads move up and get decrypted.

Errors can come from several layers. A refused TCP connect is a plain networking failure. A TLS version mismatch surfaces after TCP connects, partway through the handshake. A write that fails after the handshake might just be the lower socket getting reset. So the first thing to figure out when something breaks is where it broke - before TCP, during TCP, during the handshake, during certificate checks, or after application data was already flowing.

Records Carry Protected Bytes

TLS moves everything inside records. A record is the unit the protocol works in. Each one carries either handshake messages, an alert, or encrypted application data, and each one has a small header followed by a fragment. The earliest handshake records can hold plaintext, because no keys exist yet. Once both sides have keys, the fragments are encrypted. TLS 1.3 encrypts more of the handshake than TLS 1.2, so a packet capture of the same application looks different depending on the version.

Records and TCP segments are different units, and conflating them causes a lot of confusion. TCP just moves bytes. A single TLS record might arrive spread across several TCP reads. Several records might show up in one read. And a single application write can turn into one record, several records, or part of a record, depending on buffering and how OpenSSL sizes things.

A write runs in this order -

text
write("GET / HTTP/1.1\r\n\r\n")
  -> TLS application-data record
  -> encrypted bytes
  -> TCP write

A read runs in reverse -

text
TCP read
  -> encrypted TLS record bytes
  -> record protection check
  -> plaintext application bytes
  -> stream read

Flowchart of one write fanning into TLS records and TCP segments and back

Figure 11.2 - One application write can fan out into several TLS records and several TCP segments, and reads fan back in. OpenSSL hands up plaintext only after a full record verifies.

Those encrypted fragments are protected with key material the handshake produces, the per-connection session key. The cipher math behind it is out of scope here. What you need for runtime behavior is the ordering. OpenSSL cannot decrypt application data until the handshake has produced the traffic keys. After that, every record carries encrypted application bytes, and OpenSSL only releases plaintext to Node once a record has been processed and checked.

Record protection also changes how errors surface.

A read can fail before JavaScript ever sees application data. OpenSSL might reject a record because the integrity check failed, the record was malformed, the peer sent a fatal alert, or the connection closed mid-record. Node then emits an error on the TLS socket or the server. Your application never gets a half-decrypted body from a record that failed.

Writes change too. When you call socket.write() on a tls.TLSSocket, you put plaintext into the TLS stream. The socket underneath sends encrypted records. The peer only sees your bytes after its own TLS layer decrypts them. An application log never lines up with a packet capture byte for byte, because the capture holds encrypted record bytes and the log holds the original HTTP text.

Backpressure carries through all of this. If the peer stops reading, TCP flow control slows the encrypted write path, and Node's writable stream reports that pressure back to JavaScript. Any encrypted records OpenSSL is holding for the socket still count against memory and against write completion. The stream rules from Chapter 3 all still apply. TLS just inserts record processing between your JavaScript chunks and the TCP bytes.

Record size feeds into latency and memory. TLS caps how big a plaintext fragment can be, and within that cap, OpenSSL and Node decide how to cut outgoing data into records. A small write can end up as a small record, and a flood of tiny records adds per-record overhead and can interfere with TCP. A large write gets split across several records. Most code should leave this alone and treat the socket as an ordinary stream. The packaging only becomes something you care about when you are reading packet captures, chasing CPU cost, or running a protocol that flushes constantly.

Alerts travel as records as well. An alert is how TLS signals an error or a shutdown. A peer might send a close notification, a protocol-version failure, a handshake failure, or some other alert-level error. Node usually turns the result into an Error carrying an OpenSSL-style message. That error originates in TLS state, even though it came down the same TCP connection as everything else.

JavaScript chunks, TLS records, and TCP packets live at three different layers. A lot of confused debugging comes from treating them as one, so it helps to keep them separate while you reason about a connection.

Each layer has its own unit. JavaScript deals in chunks, OpenSSL in records, TCP in bytes. The chunk you hand to socket.write() is an application-level thing. OpenSSL wraps it into a TLS record, which is a protocol-level thing. The TCP segment that eventually leaves the machine is transport output. Sometimes all three line up one to one. A parser sitting above TLS cannot assume they will.

As an example, a small HTTPS request might go out as three separate writes -

js
socket.write('GET / HTTP/1.1\r\n');
socket.write(`Host: ${host}\r\n`);
socket.end('\r\n');

Node might merge those writes before handing them to OpenSSL. OpenSSL might emit one record or several. TCP might put the encrypted output in a single packet or scatter it across many. On the other end, the read callback could get the request line and the host header together, or it could get them in separate reads. HTTP parsing copes with all of these because HTTP carries its own framing above the decrypted stream.

The same holds for reads. A peer can pack several application messages into one TLS record, or stretch one message across several records. Node's readable side gives you decrypted bytes as stream chunks, and those chunks obey the same rule as any stream from Chapter 3 - how bytes are chunked says nothing about where messages begin and end. Parsers above TLS still have to do their own framing.

Record protection adds one more rule about when data is ready. OpenSSL hands up plaintext only once it has enough encrypted bytes to process a whole record and verify it. If the socket has received half a record, JavaScript does not get half the decrypted data. Those bytes sit in lower buffers and OpenSSL state until the rest of the record arrives or the connection fails.

This shows up in latency measurements. A capture can show encrypted bytes arriving while the JavaScript 'data' event still has not fired, and that gap is often nothing to worry about. It can come from buffering, a record that has not fully arrived, backpressure, or CPU time spent inside OpenSSL. When you are chasing tight latency, measure both layers, because socket bytes and decrypted stream events are not timestamped at the same point.

Record size also turns up in write-heavy services. TLS sets a maximum plaintext fragment size, and Node lets you tune it with tlsSocket.setMaxSendFragment(size). Smaller fragments can cut how much data sits behind a single record on a lossy link, at the cost of more per-record overhead and more CPU. Most code should leave the default in place. It is still a useful method to know, because it marks where record sizing sits - under your application chunks and above the TCP output.

Connection closing has the same layering. A clean TLS shutdown sends a close-notify alert. A bare TCP close without that alert can look like an abrupt cutoff, depending on timing and OpenSSL state. Application code usually sees only 'end', 'close', or 'error'. A log that records whether the TLS side sent or received a close-notify tells you far more than one that only reports the TCP socket closing.

Packet captures, OpenSSL traces, and JavaScript logs each see a different slice of the same connection. A capture shows you encrypted records and TCP behavior. An OpenSSL trace shows TLS state and the alerts. JavaScript logs reach only the decrypted stream events and high-level errors. Which one you reach for depends on the question you are actually asking.

The Handshake Path

Before any application data moves, the two sides run a startup negotiation. That negotiation is the TLS handshake, and it starts only after the transport is ready. During it, the peers pick a protocol version, agree on the cryptographic parameters, trade key-share data, work through the server's certificate material, verify the handshake transcript, and then switch over to protected application traffic.

A normal client connection follows this order -

text
TCP connect
ClientHello
ServerHello
certificate-related messages
key exchange result
Finished messages
application data

Each line in that list stands in for more detail, but the division of work is simple. Node kicks off the operation and surfaces events. OpenSSL builds and parses the actual TLS messages. The socket underneath only carries bytes.

The client hello opens the negotiation. It lists the TLS versions the client supports, its cipher suites, key-share data, SNI, and other extensions. In effect, it states everything the client is able to do for this connection. In raw tls.connect() code, options like servername, minVersion, maxVersion, ciphers, and the ALPN settings all feed into that message. ALPN is covered on its own later, so leave it aside here.

The server hello picks from that offer according to the server's own policy. A server can only choose a version the client actually offered. It also cannot be pushed into a cipher it has turned off. So the outcome is the overlap between the two sides, narrowed by policy - version ranges, the OpenSSL security level, cipher configuration, certificate type, supported key exchange, and the server's other options.

Version selection comes first. On current Node v24 defaults, tls.DEFAULT_MIN_VERSION is TLSv1.2 and tls.DEFAULT_MAX_VERSION is TLSv1.3, unless a CLI flag or an option changes them. So a stock Node v24 process offers the range TLS 1.2 through TLS 1.3, and the two peers settle on one version inside it.

Next comes the cipher suite, the named set of cryptographic algorithms the connection will use. A TLS 1.2 suite name bundles several decisions into one string - key exchange, authentication, bulk encryption, and the integrity algorithm. A TLS 1.3 suite name is shorter, because key exchange and authentication were pulled out into extensions and separate handshake steps. So TLS 1.3 cipher output tends to read shorter and less descriptive than the TLS 1.2 names you may be used to.

Key exchange is the part of the handshake where both peers build shared key material over the network. They send each other public key-share data, and from it each computes the same shared secret. Both sides can derive that secret, but someone watching the bytes on the wire cannot work it out from what they see. The session key that protects records is then derived from that secret together with other handshake inputs.

Modern handshakes use ephemeral key exchange, which means the key-share material is generated fresh for each handshake and thrown away after. Node's defaults prefer the ephemeral ECDHE path. The effect is that the keys protecting a connection are specific to that connection. The server's long-term identity material proves who it is, while this fresh key-exchange material is what actually feeds the traffic keys.

Both peers also keep a running record of every handshake byte, in order, called the transcript. The Finished messages use it to prove that each side saw the exact same negotiation and ended up with the same handshake secrets. If anything in transit alters a handshake message, that later transcript check fails. Application data only begins once the transcript checks pass for whatever mode was negotiated.

TLS 1.3 trims what is visible during the handshake. The client sends a client hello that already includes key-share data. The server answers with its server hello, and from that point both sides can derive handshake traffic keys early. Most of the server's later handshake messages are encrypted. The Finished messages finish the move into application traffic. A capture that would have shown several plaintext handshake messages under TLS 1.2 shows much less under TLS 1.3.

TLS 1.2 works differently in ways you will see. More handshake messages stay in the clear, the cipher suite names bundle more decisions, and older key-exchange modes still live in the protocol even where current Node and OpenSSL policy refuses them. Plenty of services and clients still run TLS 1.2, and Node v24 keeps it in the default range. In practice, a TLS 1.2 failure tends to name cipher overlap or an old protocol setting more clearly than a TLS 1.3 failure does.

Certificates are delivered during the handshake too, though their structure and validation come later. For this trace, the point is that the server hands over certificate material so the client can authenticate the endpoint under its own policy. The server also sends proof tied to its identity material and to the transcript. Node surfaces the resulting authorization state through TLS socket fields and certificate APIs, and the detail of that comes in the next subchapter.

Once the Finished messages are done, TLS can carry application data. For an HTTPS client, that is when the HTTP request goes out. A custom protocol wrapped in TLS sends its first frame at the same point. The TCP connection has been open this whole time, but the application protocol cannot start until TLS has the protected stream ready.

Here is the order for a TLS 1.3 connection -

text
client -> server - ClientHello
server -> client - ServerHello
server -> client - encrypted handshake messages
server -> client - Finished
client -> server - Finished
client -> server - application data

Sequence diagram of a TLS 1.3 handshake between client and server

Figure 11.3 - A TLS 1.3 handshake in order. After ServerHello both sides hold handshake keys, so the certificate and Finished messages travel encrypted, and secureConnect and secureConnection fire once the Finished messages are exchanged.

TLS 1.2 puts more messages in the clear and uses a different key schedule, but the runtime flow is the same at a high level. The client offers, the server selects, both sides verify the transcript, and then application bytes start.

Sequence diagrams comparing TLS 1.2 and TLS 1.3 handshakes

Figure 11.4 - The same handshake under TLS 1.2 and TLS 1.3. TLS 1.2 adds a round trip and leaves the certificate and key exchange visible, while TLS 1.3 encrypts everything after ServerHello.

The client hello is where your JavaScript options become actual protocol bytes. minVersion and maxVersion bound the version offer. servername is sent as SNI. The cipher configuration becomes the offered cipher list. If session data is present, it goes out as a resumption attempt. OpenSSL serializes all of that into records and gives the bytes to Node to write to the socket.

The server hello is the first hard decision. The server chooses a TLS version, a cipher suite, and key-share parameters that work for both peers and for its own policy. A server that allows only TLS 1.3 either lands on TLS 1.3 or fails the connection. If its certificate configuration cannot satisfy the offered algorithms, it instead fails later in the handshake. A server holding several hostname contexts may have already read SNI before it picks the final identity material.

From there, TLS 1.3 switches into encrypted handshake traffic. The server's certificate-related messages, its certificate proof, and its Finished message all travel under handshake keys. The client reads those messages, applies its local policy, verifies the transcript, sends its own Finished message, and moves on to application traffic keys. A capture taken after the server hello tells you less, because those encrypted handshake records are opaque to anyone just watching the wire.

TLS 1.2 leaves more of the middle in the open. A full TLS 1.2 handshake runs server hello, certificate, key-exchange messages, and change-cipher-state transitions before any protected application data. The transcript still applies. Both peers tie the negotiation to their Finished messages, so any tampering breaks the final check. The differences are large enough that habits built on TLS 1.2 captures can steer you wrong when you look at a TLS 1.3 connection.

The transcript is behind a set of failures that otherwise look mysterious. Each peer builds up the handshake bytes in order, and the later verification runs against that built-up transcript. If a middlebox strips an extension, rewrites the offered versions, changes SNI, or alters key-share data, the two sides arrive at transcript checks that no longer agree. The connection then fails even though the only error you see says "handshake failure" or reports a raw OpenSSL alert.

The Finished messages are the point where negotiation and verification are done. Up to that point, the peers are still setting up and authenticating the connection. Once they are exchanged, OpenSSL holds the traffic keys it needs for application records. In JavaScript terms, that point is exactly where Node fires secureConnect and secureConnection.

Where Node and OpenSSL Keep State

The handshake runs across several layers. From JavaScript it can read as a single call - tls.connect() returns a socket, and some time later secureConnect fires. Underneath, the state runs through the lower socket, Node's native TLS wrapper, OpenSSL, and the event loop.

Start at the lower socket. On a client, Node either makes a net.Socket for you or wraps one you handed in. That socket goes through DNS and TCP connect exactly as in the networking chapter. Once TCP is connected, Node has a stream that can send and receive bytes. To TLS, those bytes only mean anything once OpenSSL starts parsing them as records.

Node then builds native TLS state on top of that stream. The public tls.TLSSocket holds the JavaScript stream state, the event listeners, timeout behavior, and the public TLS methods. The native side links that JavaScript object to an OpenSSL SSL connection object.

That OpenSSL object holds the protocol state. That covers client or server mode, the configured minimum and maximum versions, the cipher list, certificate identity or trust settings, the SNI value, session cache inputs, the negotiated version, the selected cipher, the traffic secrets, any pending outbound records, any pending inbound plaintext, and alert state.

The handshake begins by asking OpenSSL for bytes to send. In client mode, OpenSSL produces a client hello. Node takes those bytes and writes them through the lower socket, and the event loop takes care of write readiness and completion. JavaScript normally sees none of this. The tls.TLSSocket is already in your hands, but the secure event has not fired.

Incoming bytes come in through the normal socket read path. libuv signals readability, Node reads from the socket, and the native TLS wrapper hands the encrypted bytes to OpenSSL. OpenSSL parses the records and handshake messages. Depending on what arrived, it may produce more outgoing handshake bytes, update its internal state without any output, or fail outright. When it does produce outbound data, Node writes that back to the socket. Once the handshake completes and OpenSSL starts producing decrypted application bytes, Node pushes those into the readable side of the tls.TLSSocket.

That cycle repeats until the handshake reaches the connected state or fails. The JavaScript call stack that created the socket is long gone while most of this runs. The event loop keeps delivering I/O readiness, and OpenSSL advances its state machine each time enough bytes have arrived.

A few public events line up with stages of that process.

On a client, the callback you pass to tls.connect() runs on the secureConnect event. It tells you the handshake finished from the client's side. If certificate verification is on, Node also has the authorization result by then. But a local test with rejectUnauthorized: false can still reach secureConnect while recording an authorization failure on the socket, so the event alone is not proof that your application trusts the peer. It only tells you the TLS connection reached its secure state. Whether to trust the peer can still be a separate decision your code makes.

On a server, three events line up with three stages. connection fires for the accepted TCP socket. secureConnection fires for a completed TLS server connection. tlsClientError fires for failures that happen before any secure connection exists. Splitting them out helps in logs. A server that logs only secureConnection never sees the clients that connect and then fail version negotiation, speak the wrong protocol, miss a required SNI, or time out mid-handshake.

Handshake timeout is a server-side policy in Node. The handshakeTimeout server option sets how long the server waits for the handshake to finish, and current v24 defaults to 120 seconds. When it trips, the server emits tlsClientError. Because no HTTP request has been parsed at that stage, the failure sits in TLS startup, well before any HTTP timeout could apply.

A busy TLS server is much easier to reason about if you count three things separately.

Start with accepted TCP sockets. Each one has taken a descriptor and some lower socket state. Some are still waiting on client hello bytes, and some have already failed below TLS.

Then there are handshakes in progress. These have TLS state attached and are either spending CPU inside OpenSSL or waiting on bytes from the peer.

Last are the secure sockets that made it to application code. Those are ready to carry HTTPS or whatever other protocol runs on top.

With those three counts apart, an overload is much easier to read. Many accepted sockets but few handshakes points to slow or broken clients. Handshakes piling up while CPU headroom drops means you are paying TLS startup cost. Plenty of secure sockets while requests stay slow means the connection is already past TLS, so the problem is up at the application protocol layer.

Node gives you the events to build those counters -

js
server.on('connection', () => tcpCount++);
server.on('secureConnection', () => secureCount++);
server.on('tlsClientError', () => tlsErrorCount++);

Real counters also need to decrement when sockets close. The mapping stays simple, though - connection sits below TLS, secureConnection comes after TLS, and tlsClientError is the failed startup in between.

Memory is layered the same way. JavaScript may be holding Buffers you wrote, Node may be holding pending encrypted output, OpenSSL keeps its handshake and record buffers, and the kernel keeps its own send and receive buffers. A connection that stalls during the handshake can tie up memory and a descriptor before your application ever sees a request. Public-facing TLS servers combine handshake timeout, connection limits, load balancer policy, and kernel limits to keep that under control.

All of the OpenSSL configuration sits in the TLS config Node passes down - identity material, trust settings, the protocol range, ciphers, curves, and related options. tls.createServer() assembles that config from its options. SNI can pick a different config per hostname. HTTPS servers use this more directly later on.

Step back and a tls.TLSSocket is an ordinary stream with one extra stage, TLS record processing. Whatever you read off it has already passed record protection, and whatever you write still has to become records before it leaves.

TLS 1.2 and TLS 1.3 in Node Logs

Once the handshake finishes, you can read the negotiated version -

js
socket.on('secureConnect', () => {
  console.log(socket.getProtocol());
});

getProtocol() returns the TLS version negotiated for the connection. Wait for the handshake before you trust the value. After the handshake it gives you the single version that was selected, and a client that allows both TLS 1.2 and TLS 1.3 can report either one, depending on what the server chose.

When both peers support TLS 1.3, that is normally what gets negotiated. It saves a round trip over a full TLS 1.2 handshake, encrypts more of the handshake earlier, and changes what the cipher suite names mean. For Node developers, those differences mostly show up in packet captures, OpenSSL trace output, and getCipher() results. Application code rarely has to do anything different.

TLS 1.2 is still in Node's default range. Leaving it on keeps you working with services that have not moved to TLS 1.3 yet. It also keeps the older debugging vocabulary around - cipher suites like ECDHE-RSA, AES, and GCM with a hash label, plus errors about shared cipher overlap. Even with TLS 1.2 allowed, current OpenSSL policy can still turn down a weak TLS 1.2 configuration.

When compatibility is part of the contract, configure the range directly -

js
const options = {
  minVersion: 'TLSv1.2',
  maxVersion: 'TLSv1.3',
  honorCipherOrder: true,
};

minVersion sets the lowest TLS version the context will negotiate, and maxVersion sets the highest. Together they narrow the default range. Push minVersion up to TLSv1.3 and TLS 1.2 peers get rejected during the handshake. Drop it below TLSv1.2 and you may force weaker OpenSSL security settings, which is something to put through a compatibility review first.

honorCipherOrder is a server-side option that tells OpenSSL to prefer the server's cipher order over the client's, for any negotiated protocol that uses that choice. It mostly applies to TLS 1.2, since TLS 1.3 negotiates ciphers another way. In Node's options, it maps straight to OpenSSL's server-preference flag.

Cipher negotiation has two stages - shared protocol ability, then local policy.

The client advertises the cipher suites it supports for the versions it offered. The server compares that against its own enabled suites and security policy. On top of that, OpenSSL applies its security level, the algorithms actually available, certificate compatibility, the supported groups, and what the build supports. Whatever cipher survives all of those checks is the one that gets used.

Turning TLS 1.2 on does not mean the handshake will succeed. TLS 1.2 is only the version. The peers still need an overlapping cipher suite, compatible key exchange, compatible signature algorithms, and identity material that fits the chosen path. A server stuck on an old RSA-only config and a client with a modern, restricted cipher list can fail even though both speak TLS 1.2. A server running only TLS 1.3 can disregard the old TLS 1.2 cipher vocabulary entirely, because TLS 1.3 negotiates these pieces in a different way.

Node gives you the policy options, and OpenSSL enforces them -

js
const server = tls.createServer({
  key,
  cert,
  ciphers: 'TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256',
});

The ciphers string takes OpenSSL cipher names. Reach for it rarely in application code. Node's defaults follow current OpenSSL and platform policy more reliably than most hand-written lists do. If you do maintain a custom list, keep it somewhere with tests, rollout notes, and a clear compatibility contract.

Server preference picks the winner when several shared suites survive -

js
const server = tls.createServer({
  key,
  cert,
  honorCipherOrder: true,
});

With that set, the server asks OpenSSL to prefer its own configured order wherever the negotiation honors server preference. Under TLS 1.2 that can decide which shared suite wins. Under TLS 1.3 the visible effect is smaller, because some of those choices moved elsewhere. Log the negotiated protocol next to the cipher before you read much into honorCipherOrder.

Cipher names come from different eras. ECDHE-RSA-AES128-GCM-SHA256 is a TLS 1.2 style name. It spells out the key exchange, the certificate authentication family, the record algorithm, the mode, and the hash label. TLS_AES_256_GCM_SHA384 is a TLS 1.3 style name. It carries record protection and the hash, while key exchange and authentication are settled elsewhere. Try to read both with the same mental model and your debugging notes will be wrong.

Application code rarely needs to know which acceptable cipher won. Platform teams, regulated environments, and interop work do. For ordinary service code, only a few checks are needed - the version is within policy, the peer was authorized under the trust settings, and the connection can move application bytes. Cipher logs are mostly for diagnosing a policy mismatch or an unexpected fallback.

Read cipher output together with the version -

js
socket.on('secureConnect', () => {
  const cipher = socket.getCipher();
  console.log(cipher.name);
  console.log(cipher.version);
});

tlsSocket.getCipher() returns data about the selected suite. Its name field is the OpenSSL cipher name. Its standardName field, when present, gives the IETF-style name. Its version field reports the minimum protocol version for that cipher suite family, which trips people up on TLS 1.3 connections. For the version actually negotiated, use getProtocol().

Version and cipher failures land before any application bytes move. A client might emit a protocol-version alert, a handshake failure, or a no-shared-cipher error. The exact wording comes from OpenSSL and shifts between Node and OpenSSL builds. So in logs, capture err.code, err.message, your local options, the target host and port, the SNI value, and whether the lower TCP connection even succeeded.

SNI Chooses the Hostname Early

SNI stands for Server Name Indication, an extension the client adds to its client hello to name the host it wants. The server can read that name while the handshake is still in progress.

HTTP already has a Host header, but the server cannot read encrypted HTTP headers until TLS has finished. A server hosting several names on one address needs to know the hostname before then. SNI carries it in the client hello, so the server can pick the right certificate and TLS policy before any application data exists.

On the client, it is one option -

js
const socket = tls.connect({
  host: '203.0.113.10',
  port: 443,
  servername: 'api.example.com',
});

The TCP connection goes to an IP address. The SNI value names which TLS server identity you actually want. Hostname policy comes up again during certificate validation, and the certificate chain and hostname matching details come later. For now, treat SNI as the thing that selects the server early.

Leave SNI out and several things can go wrong. The server might present a default certificate that does not match the host you wanted, refuse the handshake, or drop to a default TLS policy with a different cipher or version set. In Node, that shows up as a certificate authorization error, a handshake alert, or, worst of all, a connection that succeeds against the wrong server context.

This trap is easy to fall into with low-level clients, because raw tls.connect() sends SNI only when servername is set. Pass just an IP address and the TLS layer has no hostname to send at all. Pass a hostname as host and you still have to set servername to that same DNS name, unless you have a reason to send something different.

Set it to the DNS name the peer expects for that secure endpoint. Use that name, not a proxy name, a local bind address, or a resolved IP. SNI takes part in server-side selection before certificate validation ever reports back.

One wrong value here is behind a lot of confusing default-certificate failures.

On the server side, SNI arrives before HTTP has even started.

On a server, SNI can drive which context gets used -

js
const server = tls.createServer({
  key,
  cert,
  SNICallback(servername, cb) {
    cb(null, contexts.get(servername));
  },
});

That snippet is just the mechanism. SNICallback makes more sense alongside HTTPS server setup and certificate selection, which come later. For the handshake, the point is timing - the callback runs because SNI showed up in the client hello, before the server has taken any application data.

SNI also feeds into session reuse. A resumed session is tied to the TLS identity and policy that first created it. Clients normally send the same SNI name when they try to resume. TLS 1.3 allows a different SNI value only when that name is valid for the certificate from the original session and some external signal confirms the ticket scope covers both names. Servers can gate ticket acceptance on SNI, ticket keys, certificate context, and local policy. Under TLS 1.2, a server falls back to a full handshake when the resumption ClientHello carries a different SNI name.

Session Resumption Keeps Handshake State

A full TLS handshake costs round trips and CPU. Session resumption lets a later connection reuse TLS state from an earlier one, so its startup is cheaper.

The client connects once, and the server gives it resumption state to keep. On a later connection, the client offers that state in its new handshake. If the server accepts, both sides derive fresh traffic keys from the resumed state plus the current handshake inputs, and skip part of the full authentication path.

Your application still gets a new tls.TLSSocket either way. The TCP connection underneath is new too, unless an HTTP keep-alive pool reused an already-secure socket. Those are separate cases, so keep them apart.

The two reuse different things. Session resumption reuses TLS startup state, while HTTP keep-alive reuses a whole open HTTP/TLS/TCP connection. An HTTPS agent does both over time - it serves a request on an already-open TLS socket, then later, when it has to open a fresh socket to the same origin, it offers TLS session data to cut that handshake short.

One common form is the session ticket, resumption data the server issues that the client can store and present later. In TLS 1.3, ticket-based resumption is the standard approach. Depending on the implementation, the server either keeps the real state encrypted inside the ticket or holds it server-side. From Node application code, the part you deal with is simpler - the client may receive session material, cache it, and hand it back on a later tls.connect().

Node exposes session material through events and options -

js
let cached;

socket.on('session', session => {
  cached = session;
});

The session event hands client code session data whenever OpenSSL produces it. TLS 1.3 servers can issue tickets after the handshake, so this event can fire after secureConnect. If you want to cache tickets, you have to keep the socket alive long enough for them to arrive.

A later connection can offer that cached data back -

js
const socket = tls.connect({
  host,
  port: 443,
  servername: host,
  session: cached,
});

When the server accepts the resumption attempt, the handshake is shorter. When it rejects, the connection falls back to a full handshake. A rejection can come from an expired ticket, rotated ticket keys, a changed SNI value, a changed server policy, or state the client offered from a different context.

Debugging resumption comes down to keeping three things apart. The TCP connection is always new. Its TLS session might be reused. And the HTTP request on top might or might not ride an already-open socket. A log that rolls all three into one connection-reuse flag tells you nothing about which layer actually saved work.

Node reports reused TLS session state through isSessionReused() on the socket. Check it after the handshake -

js
socket.on('secureConnect', () => {
  console.log(socket.isSessionReused());
});

Read it next to HTTP client logs like request.reusedSocket. isSessionReused() tells you whether TLS resumed an earlier session while opening a new secure connection. request.reusedSocket tells you whether the HTTP request rode an already-open socket from the agent pool.

Session resumption is purely an optimization, and correctness should never lean on it. A service has to work fine doing a full handshake every single time. Resumption only trims startup cost when both peers happen to hold compatible state. So when reuse stops happening, treat it as a performance question first, before you go looking for a functional bug.

The Node API Surface During Handshake

Small TLS programs tend to confuse two moments - creating the socket and the socket being ready for secure traffic. Keep the two apart in your code.

tls.connect() returns immediately -

js
const socket = tls.connect({
  host,
  port: 443,
  servername: host,
});

socket.write('GET / HTTP/1.1\r\n\r\n');

That code queues a write on a TLS stream that may still be mid-handshake. Node will buffer the application data until the secure state is ready, then encrypt and send it. When you want the sequencing to be obvious in protocol code, write after secureConnect instead -

js
socket.on('secureConnect', () => {
  socket.write('GET / HTTP/1.1\r\n');
  socket.write(`Host: ${host}\r\n`);
  socket.end('\r\n');
});

The second version is easier to debug. The request goes out only after version selection, cipher selection, SNI handling, and certificate checks are all done. HTTPS clients keep this ordering out of sight, because https.request() manages the TLS socket itself and writes the HTTP at the right moment.

The callback form attaches to the same readiness event -

js
const socket = tls.connect(options, () => {
  socket.end('ping\n');
});

Servers follow the same split. tls.createServer() lets you see accepted TCP connections through connection, but application protocol code goes in the secure callback or the secureConnection listener -

js
const server = tls.createServer(options, socket => {
  socket.on('data', chunk => {
    socket.write(chunk);
  });
});

That callback gets decrypted data chunks. Those bytes have already been through TLS record processing. When the server writes plaintext back into the TLS socket, OpenSSL encrypts it before TCP sends it.

Log the inspection APIs after the handshake -

js
function logTls(socket) {
  console.log(socket.getProtocol());
  console.log(socket.getCipher().standardName);
  console.log(socket.authorized);
}

socket.authorized is certificate-validation state, and the full explanation of it comes in the next subchapter. Even so, logging it next to the protocol and cipher helps you tell "handshake selected TLS 1.3" apart from "peer identity passed local trust policy".

Version and cipher options go in the TLS options you pass when creating the server -

js
const server = tls.createServer({
  key,
  cert,
  minVersion: 'TLSv1.2',
  maxVersion: 'TLSv1.3',
  honorCipherOrder: true,
});

Those options apply to handshakes for sockets that server creates. Changing the JavaScript variables afterward does not rewrite a handshake already underway. Existing TLS sockets hold on to the state they negotiated.

Client options follow the same pattern -

js
const socket = tls.connect({
  host,
  port: 443,
  servername: host,
  minVersion: 'TLSv1.3',
});

That client makes TLS 1.3 its minimum. A server that only does TLS 1.2 will fail during the handshake, and that failure is exactly what the configured policy asks for. Include the configured range in your logs so the failure reads as a policy mismatch and not as some random network problem.

tls.TLSSocket can also wrap a socket you already have. This comes up with proxy tunnels and protocol upgrades -

js
const secure = tls.connect({
  socket: tunnelSocket,
  servername: host,
});

A forward-proxy CONNECT tunnel from Chapter 10 gives the client a raw byte stream to the destination. The TLS handshake then runs over that stream. The proxy just forwards bytes, unless it terminates TLS itself. From the client's side, the supplied socket becomes the lower transport for tls.connect().

TLS slots in between plain networking and HTTPS. Node can wrap an ordinary TCP socket, a tunneled socket, or a socket a higher-level agent created. All the TLS layer asks for underneath is a byte stream that arrives in order.

Alerts and Startup Failures

TLS startup failures all happen before HTTP exists.

A client connects to port 443, sends TLS, and the server turns down the protocol version. A proxy returns an HTTP response where the client was waiting for a TLS record. The server demands SNI and the client sends none. A cipher policy leaves no shared option. Or the handshake simply runs out of time. Every one of these fails before a request line or an HTTP header could mean anything.

TLS uses alerts to signal both clean shutdowns and errors. Some alerts are routine closure signals. A fatal alert aborts the connection. OpenSSL turns many of them into error messages that Node then emits. The wording shifts between builds, but the position is steady - TCP connected, TLS started, handshake failed.

Server logs should keep TCP accept, TLS completion, and TLS client errors apart -

js
server.on('tlsClientError', (err, socket) => {
  console.error(err.code, socket.remoteAddress);
});

tlsClientError fires for a server-side TLS error that happens before a secure connection exists, handshake timeout included. A handler that only logs application requests never sees these, because the request listener never runs for them.

Client code needs an error listener before it does anything real -

js
socket.on('error', err => {
  console.error(err.code);
  console.error(err.message);
});

That error could be a lower socket error or a TLS error. Add the context yourself - target host and port, the SNI value, the configured versions and ciphers, the proxy path, and whether this was a fresh socket or a resumed-session attempt.

Version mismatch is the simplest case. The client's allowed range and the server's allowed range have no overlap. Set minVersion: 'TLSv1.3' on a Node client and it fails against a server that only speaks TLS 1.2. Set the same on a Node server and it turns away TLS 1.2 clients. Either way, OpenSSL reports it during the handshake, because there is no shared version to land on.

Cipher mismatch sits a step below that. The peers agree on a TLS version, but policy leaves no usable cipher suite or key-exchange path. Under TLS 1.2, the ciphers option and server preference can cause it. Under TLS 1.3, cipher configuration means something narrower, and supported groups, certificate type, signature algorithms, and OpenSSL security policy all feed in too. Do not try to debug these by staring at a single cipher name. Log the negotiated version if the handshake got far enough to have one, plus the policy you configured.

Wrong protocol is louder. Aim a TLS client at a plain HTTP server and OpenSSL usually reports a "wrong version number" or a packet-format error, because the first bytes coming back are HTTP text where it expected a TLS record. Go the other way, an HTTP client against a TLS server, and you get an HTTP parser error or an early socket close, since the first bytes are TLS records instead of an HTTP start line. Both are port mistakes. The parser just breaks in a different spot each way.

Missing SNI is hostname policy showing up early. Public servers often host several names on one address. With no SNI, the server may fall back to a default context or just reject the handshake. If a low-level TLS client fails where https.request() works against the same hostname, check the raw client's servername option before anything else.

Handshake timeout is really a resource limit. The peer connected but never finished TLS startup inside the window the server allows. Slow clients, broken proxies, blocked writes, CPU pressure, and clients speaking some other protocol can all end up here. Through that whole wait the server is holding a descriptor and some TLS state, so production systems usually pair TLS timeout logs with connection counters.

Proxy paths add one more place for early trouble. Over an HTTP CONNECT tunnel, the client expects the proxy to send back a success response and then stop parsing HTTP on that connection. The TLS client starts right after that response and sends its client hello through the tunnel. If the proxy returns an HTTP error page, injects authentication text, closes the tunnel, or buffers bytes wrong, OpenSSL ends up with non-TLS input or an early close. The error can look like a TLS failure even though the broken piece is one hop upstream. Log the proxy status, the tunnel target, and the first layer that failed before you touch TLS policy.

It is easy to read too much into secureConnect. The event tells you the TLS client reached its secure connection state. On default verification settings, it also means Node accepted the server certificate for that connection. Once code disables verification, pins its own trust, or defers identity decisions to the application, the event on its own means less. Take it as the start of protected byte exchange, and let the next layer make the authorization call.

The first application byte only goes out after all of that. TCP is connected, TLS has picked a version and cipher, SNI has steered the early server selection where it was used, and session reuse may have trimmed some of the work. With OpenSSL now in application-traffic mode, GET / HTTP/1.1 is finally the next thing Node sends for an HTTPS request.