# How the TLS Handshake Works (Explained)

URL: https://www.devopsness.com/blog/tls-handshake-explained
Author: Kiril Urbonas
Published: 2026-08-07
Updated: 2026-10-31
Categories: Infrastructure
Tags: TLS, Ssl, Security, Networking, HTTPS

Summary: A developer-focused walkthrough of the TLS 1.3 handshake, certificate trust, forward secrecy, and how to debug the errors you actually hit.

---
# How the TLS Handshake Works (Explained)

A TLS 1.3 handshake takes one round trip: the client sends a ClientHello carrying its supported ciphers, SNI hostname and an ephemeral key share, the server replies with its key share, certificate and Finished message, and both sides derive the same symmetric keys. TLS 1.2 took two round trips. Debug failures with `openssl s_client`, which names the exact problem.

> [!VERIFIED]
> TLS 1.3 per RFC 9846 (which obsoletes RFC 8446), Encrypted Client Hello per RFC 9849, OpenSSL 3.5 LTS and 4.1, against the rfc-editor.org texts and the docs.openssl.org s_client man page. Checked October 2026.

## What TLS actually gives you

TLS (the modern name for what people still call SSL) adds three guarantees to a TCP connection: **encryption** (nobody on the path can read the traffic), **integrity** (a flipped byte is detected and the connection torn down), and **authentication** (you are talking to the real server, which is the certificate part and the part that fails most often).

The trick is combining two kinds of cryptography. Asymmetric math runs briefly to agree on a shared secret over an open wire, then both sides switch to fast symmetric ciphers like AES-GCM or ChaCha20-Poly1305 for the data. The handshake is the bridge between the two.

## The TLS 1.3 handshake, step by step

**1. ClientHello.** The client lists its cipher suites, the TLS versions it speaks, and, crucially, a key share. Instead of waiting to be told which key-exchange group to use, it guesses one and sends its ephemeral public key immediately. OpenSSL 3.5 changed its default key shares to offer the post-quantum hybrid `X25519MLKEM768` alongside `X25519`. The ClientHello also carries Server Name Indication (SNI), the hostname it wants, so a server hosting many sites knows which certificate to present.

**2. ServerHello and the encrypted flight.** The server picks a cipher suite and a group and replies with its own key share. Both sides can now compute the same shared secret with ephemeral Diffie-Hellman. Everything after ServerHello is encrypted: EncryptedExtensions, the Certificate, a CertificateVerify signature proving the server holds the private key, and Finished.

**3. Client verification and Finished.** The client validates the certificate chain, checks CertificateVerify and the server's Finished, sends its own Finished, and application data flows.

If the client guessed a group the server does not support, the server sends a HelloRetryRequest and the client retries, costing an extra round trip.

### How TLS 1.2 differed

TLS 1.2 needed two round trips. The ClientHello carried only a cipher list; the server answered with its choice and certificate; only then did the client send key material, followed by another exchange of Finished messages. TLS 1.3 collapses this by sending the key share speculatively and by cutting the cipher list to a few AEAD suites. RSA key transport, static Diffie-Hellman and CBC-mode suites are gone.

## Certificates and the chain of trust

The server presents a leaf certificate for its hostname. The leaf is signed by an intermediate, which is signed by a root certificate authority. Your OS or browser ships a list of trusted roots.

The client walks the chain: each certificate must be signed by the next, none may be expired, the chain must end at a trusted root, and the requested hostname must appear in the leaf's Subject Alternative Name list. The classic misconfiguration is a server that sends its leaf without the intermediate. Browsers often paper over it with cached intermediates; `curl`, Java, Go and Python clients use only what the server sends plus their local roots, so the same site works in Chrome and fails in CI with `unable to verify the first certificate`.

## Forward secrecy, SNI, and resumption

**Forward secrecy:** because the key shares are ephemeral, an attacker who records traffic today and steals the server's private key next year still cannot decrypt those sessions. the TLS 1.3 spec states that all its public-key key exchange modes provide forward secrecy.

**SNI and ECH:** the hostname travels in the ClientHello, so one IP can host thousands of sites. Classic SNI is sent in the clear, which is the gap Encrypted Client Hello (RFC 9849) closes by encrypting the inner ClientHello with a key the server publishes in DNS.

**Resumption and 0-RTT:** after a handshake the server can issue a ticket. Next time the client presents it and skips most of the negotiation, and it can even send early data in its first flight. The RFC is blunt about the cost: "There are no guarantees of non-replay between connections," and 0-RTT data is not forward secret. Send only idempotent requests that way, never a payment.

## Inspecting a handshake

The fastest way to see what a server presents is `openssl s_client`. `-servername` sends SNI, which multi-site hosts need, and `-brief` prints a short summary:

```
$ openssl s_client -connect example.com:443 -servername example.com -brief </dev/null
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=example.com
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
```

Exact lines vary by OpenSSL version and server. To see the chain the server actually sent (not the one your client built), and to make verification failures abort with an error:

```
$ openssl s_client -connect example.com:443 -servername example.com \
    -showcerts -verify_return_error -verify_hostname example.com </dev/null
```

Force a version to test what a server accepts, and check the leaf's dates and names without parsing the whole dump:

```
$ openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null
$ openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
    | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
```

`curl -v https://example.com` gives a quicker read in one line: look for `SSL connection using TLSv1.3` and the subject and issuer.

## The error strings, and what each one means

These are OpenSSL's verify messages, as printed in `Verify return code:` or a `verify error:` line. Other TLS stacks wrap the same causes in different words.

| Error text | Cause | Fix |
|---|---|---|
| `certificate has expired` (code 10) | Leaf or intermediate past its `notAfter` date, or the client clock is wrong | Renew and automate renewal; check `date` on the client |
| `unable to verify the first certificate` (code 21) | Server sent the leaf without its intermediate | Serve the full chain (`fullchain.pem`, not `cert.pem`) |
| `unable to get local issuer certificate` (code 20) | Client has no root for this chain, often a stale CA bundle in a container | Install `ca-certificates`, or add the internal root |
| `self-signed certificate` (code 18) / `self-signed certificate in certificate chain` (code 19) | Internal CA or a TLS-inspecting proxy | Trust that root explicitly; never disable verification in prod |
| `hostname mismatch` (code 62) | Requested name not in the leaf's SAN list | Reissue with the name, or connect with the right hostname |
| `alert handshake failure` / `alert protocol version` | No common protocol version, cipher or group | Compare `-tls1_2` and `-tls1_3` results; upgrade the old side |

A wrong certificate with no other symptoms is often missing SNI: without `-servername`, many hosts return their default certificate. For TLS terminating at a Kubernetes ingress, [fixing ingress 502 and 504 errors](/blog/fix-ingress-502-504) covers the backend half of the same failures.

## Where this breaks

This walkthrough describes plain server-authenticated TLS over TCP. Mutual TLS adds a client certificate and its own failure modes; see [mTLS for service-to-service auth](/blog/mtls-service-to-service-auth-beyond-api-keys). QUIC and HTTP/3 run the TLS 1.3 handshake inside QUIC packets, so `openssl s_client` over TCP will not show you that connection. TLS-inspecting corporate proxies re-sign every certificate, which makes the chain you see differ from the one the server sent. And post-quantum key shares make the ClientHello larger, which has exposed middleboxes that drop big first packets.

## Questions people ask

### How many round trips does a TLS 1.3 handshake take?

One for a full handshake, assuming the server accepts the key share the client guessed. The client sends ClientHello with a key share, the server responds with its key share, certificate and Finished in the same flight, and application data follows. If the server needs a different group, a HelloRetryRequest adds a second round trip. Resumed sessions with 0-RTT send data in the first flight, at the cost of replay protection.

### What is the difference between SSL and TLS?

SSL is the old protocol family from Netscape; every SSL version is deprecated and insecure. TLS replaced it, and the current version is TLS 1.3, now specified in RFC 9846. People still say "SSL certificate" and "SSL error" out of habit, but any modern connection is TLS. Servers should accept TLS 1.3, fall back to TLS 1.2 if old clients need it, and refuse everything older.

## The call we'd make

Serve TLS 1.3 with TLS 1.2 as a fallback, disable everything older, and let the library choose ciphers and groups; current OpenSSL defaults already prefer hybrid post-quantum key exchange. Automate certificate renewal, always serve the full chain, and reserve 0-RTT for idempotent requests. When a handshake fails, run `openssl s_client` before you guess, because the verify code usually names the exact problem. For how this sits above TCP and DNS, see our [networking fundamentals guide](/blog/networking-fundamentals-guide), and once TLS is solid, harden what rides on top with proper [HTTP security headers](/blog/security-headers-guide).

## Sources

- [RFC 9846: The Transport Layer Security (TLS) Protocol Version 1.3](https://www.rfc-editor.org/rfc/rfc9846)
- [RFC 9849: TLS Encrypted Client Hello](https://www.rfc-editor.org/info/rfc9849/)
- [OpenSSL docs: openssl-s_client(1) man page](https://docs.openssl.org/master/man1/openssl-s_client/)
- [OpenSSL 3.5 release notes (default X25519MLKEM768 key shares)](https://openssl-library.org/news/openssl-3.5-notes/)

