Skip to content
Blog

TLS Handshake Explained: Steps in TLS 1.3 and TLS 1.2

TLS Handshake Explained: Steps in TLS 1.3 and TLS 1.2
In this article
  1. What the handshake has to achieve
  2. The TLS 1.3 handshake, step by step
  3. The TLS 1.2 handshake: one extra round trip
  4. We timed it: 1 round trip versus 2
  5. Session resumption and 0-RTT
  6. How to see a TLS handshake yourself
  7. Handshake settings worth checking on your server

The TLS handshake is the short conversation a browser and a web server have before any HTTPS page data is sent. In it they agree on a TLS version and cipher, create shared encryption keys, and the server proves with its certificate that it really is the site you asked for. With TLS 1.3 this takes one network round trip; with the older TLS 1.2 it takes two. People still call it the “SSL handshake”, but SSL itself is long retired: RFC 9325 says implementations must not negotiate SSL 2 or SSL 3.

Terminal showing openssl s_client output with TLSv1.3 and TLS_AES_256_GCM_SHA384
A real TLS 1.3 handshake with example.com: protocol version, cipher suite and the server certificate

What the handshake has to achieve

The TLS 1.3 standard, RFC 8446, splits the handshake into three phases:

  1. Key exchange: agree on the cryptographic parameters and create shared keying material. Everything after this phase is encrypted.
  2. Server parameters: settle the remaining options, such as which application protocol (HTTP/2 or HTTP/1.1) to use and whether the client must show a certificate too.
  3. Authentication: the server proves it owns the private key for its certificate, and both sides confirm that nobody tampered with the messages so far.

If any part fails, the connection is closed, often with a TLS alert message, and no page data is sent at all. That is what the browser reports as a handshake or secure connection error.

The TLS 1.3 handshake, step by step

To see the real messages, we ran nginx 1.31.6 in a Docker container with a test certificate for shop.example.test and connected with the OpenSSL 3.5.8 s_client tool. The -msg option prints every handshake message (we removed the record-header lines):

$ openssl s_client -connect shop.example.test:443 -servername shop.example.test -tls1_3 -msg -brief
>>> TLS 1.3, Handshake [length 05c0], 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 01bd], Certificate
<<< TLS 1.3, Handshake [length 0050], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Negotiated TLS1.3 group: X25519MLKEM768

>>> is the client sending, <<< is the server answering. Here is what each message does:

  1. ClientHello: the client sends a random number, the TLS versions and cipher suites it supports, and its half of a key exchange in the key_share extension. It also names the site it wants (SNI, server name indication) and the protocols it can speak (ALPN, for example h2 for HTTP/2).
  2. ServerHello: the server picks the version and cipher suite and sends its own key share. From these two messages both sides calculate the same secret, and every message after this point is encrypted.
  3. EncryptedExtensions: the answers to the client’s other requests, such as the chosen ALPN protocol.
  4. Certificate and CertificateVerify: the server sends its certificate chain, then a signature over the whole handshake made with the certificate’s private key. That signature is the proof of identity: a copied certificate is useless without the key.
  5. Finished (both sides): a MAC over the entire handshake. If anyone changed a single byte on the way, the values would not match and the connection would stop.

The two ChangeCipherSpec lines are not real TLS 1.3 messages. RFC 8446 (Appendix D.4) describes this “middlebox compatibility mode”: dummy records that make the handshake look like TLS 1.2 so that old firewalls and proxies let it through.

Note the group X25519MLKEM768 and the large ClientHello (0x05c0, 1,472 bytes). OpenSSL 3.5 added hybrid post-quantum key exchange and offers it by default. The OpenSSL documentation warns that in rare cases the bigger ClientHello no longer fits in one TCP segment and badly behaved firewall software can break the handshake, so keep this in mind if a firewall is in the path and only new clients fail.

The TLS 1.2 handshake: one extra round trip

The same server and client, forced to TLS 1.2 with -tls1_2:

>>> TLS 1.2, Handshake [length 00d4], ClientHello
<<< TLS 1.2, Handshake [length 0045], ServerHello
<<< TLS 1.2, Handshake [length 01ba], Certificate
<<< TLS 1.2, Handshake [length 0074], 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 00da], NewSessionTicket
<<< TLS 1.2, Handshake [length 0010], Finished
Protocol version: TLSv1.2
Ciphersuite: ECDHE-ECDSA-AES256-GCM-SHA384

(The server also sends a ChangeCipherSpec before its Finished; OpenSSL shows it only as a bare record header, so it is not in the list.) In TLS 1.2 the client does not send a key share in its first message. It has to wait for the server’s hello, certificate and key exchange, then send its own key exchange, and only after the server’s Finished comes back can the page be requested. The certificate also travels unencrypted, so anyone watching the network can read it.

TLS 1.2TLS 1.3
Round trips for a full handshake21
Certificate sentIn the clearEncrypted
Key exchangeECDHE, DHE or static RSA(EC)DHE, PSK, or both; static RSA and static Diffie-Hellman removed, so every public-key exchange has forward secrecy
CiphersMany, including legacy CBC suitesAEAD only (AES-GCM, ChaCha20-Poly1305, AES-CCM)
Resumed connection1 round trip (abbreviated handshake)1 round trip, or 0-RTT with early data

We timed it: 1 round trip versus 2

To make the difference visible, we added 100 ms of delay to the server container’s network with Linux tc netem, so every round trip took about 100 ms, and asked curl 8.22.0 to report how long the TLS part took. Three runs of each gave the same picture:

ProtocolTLS handshake timeRound trips
TLS 1.3102 to 106 ms1
TLS 1.2205 to 213 ms2

The handshake time is curl’s time_appconnect minus time_connect. The cryptography itself took a few milliseconds; nearly all the time was waiting for packets. That is why the round-trip count matters so much for visitors far from your server, and why moving from TLS 1.2 to 1.3 makes the first page load feel faster on slow or distant connections.

Session resumption and 0-RTT

After a handshake the server can send a NewSessionTicket. curl showed two of them arriving after our TLS 1.3 handshake. On the next visit the client presents the ticket, and the server can skip the certificate step. We saved the session with -sess_out and reconnected with -sess_in:

>>> TLS 1.3, Handshake [length 06ef], ClientHello
<<< TLS 1.3, Handshake [length 04c0], ServerHello
<<< TLS 1.3, Handshake [length 000a], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Handshake [length 0034], Finished
Reused, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384

No Certificate and no CertificateVerify this time. TLS 1.2 resumption behaved the same way: ServerHello, then Finished from both sides, one round trip instead of two.

TLS 1.3 can go one step further with 0-RTT (early data): a returning client sends its first request together with the ClientHello. RFC 8446 warns that 0-RTT data is not forward secret and that “there are no guarantees of non-replay between connections”, so an attacker could resend it. For that reason nginx has ssl_early_data off by default, and the nginx SSL module documentation says requests sent in early data are subject to replay attacks. Leave it off unless your application only accepts safe, repeatable requests in early data.

How to see a TLS handshake yourself

With curl, the verbose flag prints each handshake step and the result:

$ curl -sv https://shop.example.test/ -o /dev/null
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
* SSL certificate verified via OpenSSL.

To check which versions a server accepts, try each one with OpenSSL (on Windows, see how to install OpenSSL):

openssl s_client -connect example.com:443 -servername example.com -tls1_3 -brief </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_2 -brief </dev/null

Always pass -servername. Many sites share one IP address, and without SNI the server does not know which certificate to send. Our server refused an old TLS 1.1 attempt like this:

error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:ssl/record/rec_layer_s3.c:918:SSL alert number 70

That refusal is correct. RFC 8996 says TLS 1.0 and TLS 1.1 must not be used, and OpenSSL 3.5 would not even offer TLS 1.1 from the client side until we lowered its security level.

Handshake settings worth checking on your server

  • Protocols: allow TLS 1.2 and TLS 1.3 only. In nginx that is ssl_protocols TLSv1.2 TLSv1.3;, which is also the default in current nginx versions. RFC 9325 says implementations must support TLS 1.2, should support TLS 1.3, and must prefer TLS 1.3 when they have it.
  • A complete certificate chain: send the intermediate certificates with your own, or some clients will fail to verify it. SSL certificate not trusted covers chain, name and expiry problems.
  • Session resumption: nginx sends session tickets by default (ssl_session_tickets on), but its session cache is none unless you set one, for example ssl_session_cache shared:SSL:10m;.
  • Expiry: an expired certificate fails every handshake. Check the expiry date with OpenSSL and automate renewal.
  • Ports: HTTPS normally uses TCP 443, and mail services have their own TLS ports. The HTTPS and SSL port list has the full set.

When the handshake does fail, the error message usually points to the cause: a version mismatch, plain HTTP on a TLS port, a certificate and key that do not match, or a firewall in the way. SSL Handshake Failed: Causes and How to Fix It walks through each one with the exact messages. If you need a certificate for your site, Ucartz offers DV, OV and wildcard SSL certificates with free installation help.

Related articles

Start Building Today

Ready to Get Started?

VPS, dedicated servers, streaming and managed hosting with 24/7 support from the Ucartz team.