Ch. 20 · Networking

Networking: The TLS Handshake

Read a real TLS 1.3 handshake from openssl s_client, compare it with TLS 1.2, and diagnose the certificate errors that break HTTPS clients.

~6 min readadvancedupdated Oct 19, 2026

When an interviewer asks you to “walk through the TLS handshake”, they usually want three things: what the two sides agree on, how they end up sharing a key that nobody watching could compute, and how the client knows it’s talking to the real server. The clearest way to show all three is to watch an actual handshake, and openssl s_client lets you do that from a terminal.

All the output below is from OpenSSL 3.6 connecting to example.com, plus a small Node HTTPS server on localhost for the failure cases.

What the handshake has to accomplish

Before any HTTP is sent, the client and server must:

  1. Agree on parameters: protocol version, cipher suite and key exchange group.
  2. Establish a shared secret using an ephemeral key exchange (elliptic-curve Diffie-Hellman). Each side generates a throwaway key pair, they swap public halves, and each computes the same secret. Someone recording the traffic sees only the public halves.
  3. Authenticate the server. The server sends a certificate chain and signs the handshake transcript with the certificate’s private key, proving it holds that key.
  4. Confirm nothing was tampered with. Both sides send a Finished message containing a MAC over the whole handshake. If an attacker altered any earlier message, say to downgrade the cipher, the MACs don’t match and the connection fails.

A common misconception is that the certificate’s key encrypts the session. In TLS 1.3 it never does. It only signs. The session keys come from the ephemeral exchange, which is what gives forward secrecy: stealing the server’s private key next year doesn’t let you decrypt traffic recorded today.

A TLS 1.3 handshake, message by message

-brief summarises the result:

$ echo | openssl s_client -connect example.com:443 -servername example.com -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=example.com
Hash used: SHA256
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
Text

-msg prints each handshake message as it’s sent (>>>) or received (<<<). Trimmed to the interesting lines:

>>> TLS 1.3, Handshake [length 0601], ClientHello
<<< TLS 1.3, Handshake [length 04ba], ServerHello
<<< TLS 1.3, ChangeCipherSpec [length 0001]
<<< TLS 1.3, Handshake [length 000a], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0e66], Certificate
<<< TLS 1.3, Handshake [length 004e], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished
Text

Walk through it:

  • ClientHello lists supported versions, cipher suites and groups, the server name, and ALPN protocols (h2, http/1.1). The part that makes TLS 1.3 fast is the key share: the client guesses which group the server will choose and sends its public key up front. This one is 0x601 = 1,537 bytes, much bigger than a classic ClientHello, because OpenSSL 3.6 offers the hybrid post-quantum group X25519MLKEM768, whose key share is over a kilobyte.
  • ServerHello picks the version, cipher suite and group, and returns the server’s key share. At this point both sides can compute the handshake secret. Everything after this is encrypted.
  • ChangeCipherSpec is a dummy message. TLS 1.3 doesn’t need it, but middleboxes that expect TLS 1.2 shapes break without it, so it’s kept for compatibility.
  • EncryptedExtensions carries settings like the chosen ALPN protocol.
  • Certificate is the chain, 0xe66 = 3,686 bytes here. That’s more than two full TCP segments on a 1500-byte MTU, which is why certificate size and MTU problems show up together.
  • CertificateVerify is the server’s signature over the transcript so far, made with the certificate’s private key.
  • Finished from each side closes the handshake.

The client could send its HTTP request immediately after its Finished. Counting round trips: ClientHello out, server flight back, request out. That’s one round trip of handshake (on top of TCP’s own), compared with two for TLS 1.2.

The same server over TLS 1.2

Forcing TLS 1.2 with -tls1_2 shows the older shape:

>>> TLS 1.2, Handshake [length 00cc], ClientHello
<<< TLS 1.2, Handshake [length 0043], ServerHello
<<< TLS 1.2, Handshake [length 0e5d], Certificate
<<< TLS 1.2, Handshake [length 0073], ServerKeyExchange
<<< TLS 1.2, Handshake [length 0004], ServerHelloDone
>>> TLS 1.2, Handshake [length 0025], ClientKeyExchange
>>> TLS 1.2, ChangeCipherSpec [length 0001]
>>> TLS 1.2, Handshake [length 0010], Finished
<<< TLS 1.2, Handshake [length 00ca], NewSessionTicket
<<< TLS 1.2, Handshake [length 0010], Finished
Text

The client doesn’t send a key share in its hello, so the server sends its half in ServerKeyExchange, the client replies with ClientKeyExchange, and only then do both sides switch to encryption. That’s the extra round trip. The certificate also travels in plaintext, visible to anyone on the path. The negotiated suite was ECDHE-ECDSA-CHACHA20-POLY1305 with an X25519 temporary key, so this connection still had forward secrecy. TLS 1.2 also allowed static RSA key exchange, where the client encrypted the secret with the certificate’s key and recorded traffic could be decrypted later; TLS 1.3 removed that option entirely.

Certificates: the chain and the name

-showcerts shows what the server sent:

 0 s:CN=example.com
   i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   v:NotBefore: Sep 26 22:49:11 2026 GMT; NotAfter: Dec 25 22:56:35 2026 GMT
 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
 2 s:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
   i:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
 ...
Text

Each certificate’s issuer (i:) is the subject (s:) of the next. The client walks this chain until it reaches a root in its own trust store. Note the leaf is valid for about 90 days, which is why automated renewal matters more than it used to.

The server must send the intermediates. Browsers sometimes cope when it doesn’t, because they cache intermediates from other sites or fetch them using a URL inside the certificate. curl, Java, Go, Python and most server-side clients generally don’t. So “it works in Chrome” proves very little.

Then the name. Clients check the host they connected to against the certificate’s Subject Alternative Name list (the CN field is ignored by modern clients):

$ echo | openssl s_client -connect example.com:443 -servername example.com -verify_hostname wrong.example.org -brief
verify error:num=62:hostname mismatch
Verification error: hostname mismatch
Text

The -servername flag sets SNI, the name the client puts in its ClientHello so a server hosting many sites knows which certificate to send. Clients without SNI often get a default certificate for some other site and fail with exactly this mismatch.

The same checks bite locally. A Node HTTPS server on port 8443 with a self-signed certificate for DNS:localhost:

$ curl -sS https://localhost:8443/
curl: (60) SSL certificate problem: self signed certificate

$ curl -sS --cacert cert.pem https://localhost:8443/
hello over TLSv1.3 TLS_AES_256_GCM_SHA384 alpn=http/1.1

$ curl -sS --cacert cert.pem https://127.0.0.1:8443/
curl: (60) SSL: no alternative certificate subject name matches target ipv4 address '127.0.0.1'
Text

Trusting the certificate with --cacert fixes the first error. The third fails because the SAN lists a DNS name, not the IP. An IP needs its own IP: SAN entry. Reaching for curl -k makes all of this disappear, along with all the protection.

Resumption and 0-RTT, briefly

After the handshake, the server can send a NewSessionTicket. On a later connection the client presents it as a pre-shared key, skipping certificate verification, and TLS 1.3 can even send the first request in that first flight (0-RTT). That early data can be replayed by an attacker, so servers should only accept it for requests that are safe to repeat.

Interview exercise: “works in the browser, fails in production”

A team deploys a new certificate on api.example.internal. The web dashboard loads fine in Chrome. Their Java service calling the API now fails with PKIX path building failed: unable to find valid certification path to requested target, and curl reports unable to get local issuer certificate. What’s wrong?

Answer

Both errors mean the client couldn’t build a chain from the leaf to a trusted root. The most likely cause, given that Chrome works, is that the server is sending only the leaf certificate and not the intermediate. Chrome probably had that intermediate cached or fetched it itself; Java and curl don’t.

I’d confirm with openssl s_client -connect api.example.internal:443 -servername api.example.internal -showcerts and count the certificates. If only 0 s: appears, that’s it. The fix is to configure the server with the full chain file (leaf followed by intermediates, which most CAs provide as fullchain.pem), not to add the intermediate to every client’s trust store. If the chain is complete, the next suspects are a private CA whose root isn’t in Java’s cacerts, or a TLS-intercepting proxy in front of the Java service replacing the certificate. Turning off verification in the client is not an acceptable fix, and saying so out loud is part of a good answer.

Further study

More in Networking

read ✓Networking · hard

HTTP/3 and QUIC Transport

Why QUIC moves HTTP onto UDP, how browsers discover HTTP/3 through Alt-Svc and HTTPS records, and where its gains over HTTP/2 disappear.

~6 min readread →
read ✓Networking · hard

Network Debugging Along the Request Path

Find the first failing step in a request (DNS, TCP, TLS, HTTP or the app) with curl timings, dig, nc and openssl, using six real failures.

~6 min readread →
esc