Basics
The TLS 1.3 handshake explained, message by message
A TLS 1.3 handshake takes one round trip: the client sends a ClientHello with its key shares, the server replies with ServerHello and an encrypted flight (EncryptedExtensions, Certificate, CertificateVerify, Finished), and the client answers with its own Finished. Everything after ServerHello is encrypted. The messages below follow RFC 9846, the July 2026 revision of TLS 1.3 that replaced RFC 8446.
The full handshake at a glance
A full TLS 1.3 handshake has three flights, shown below with optional client authentication. Braces mean "encrypted with handshake keys", square brackets mean "encrypted with application keys", and * marks optional messages.
Client Server
ClientHello
+ supported_versions, key_share, signature_algorithms,
+ server_name, ALPN, psk_key_exchange_modes,
+ pre_shared_key*, early_data* -------->
(0-RTT [Application Data*]) -------->
ServerHello
+ supported_versions
+ key_share
+ pre_shared_key*
{EncryptedExtensions}
{CertificateRequest*}
{Certificate*}
{CertificateVerify*}
{Finished}
<-------- [Application Data*]
{EndOfEarlyData*}
{Certificate*}
{CertificateVerify*}
{Finished} -------->
<-------- [NewSessionTicket*]
[Application Data] <-------> [Application Data]
Three sets of keys appear: early traffic keys (only for 0-RTT), handshake traffic keys (from ServerHello to Finished), and application traffic keys (after Finished). All come from one key schedule described below.
ClientHello: the client's opening offer
The ClientHello carries everything the server needs to choose parameters and compute keys immediately. Its fixed fields are mostly legacy; the real negotiation happens in extensions.
| Field or extension | Purpose in TLS 1.3 |
|---|---|
legacy_version |
Always 0x0303 (TLS 1.2) for compatibility; the real version is in supported_versions |
random |
32 random bytes, mixed into the transcript |
legacy_session_id |
A random 32-byte value in middlebox compatibility mode, otherwise empty |
cipher_suites |
TLS 1.3 suites such as TLS_AES_128_GCM_SHA256, plus TLS 1.2 suites if the client still supports 1.2 |
supported_versions |
List of versions, for example 0x0304 (TLS 1.3) and 0x0303 (TLS 1.2) |
supported_groups |
Key-exchange groups the client accepts, in preference order (X25519MLKEM768, X25519, P-256...) |
key_share |
Actual public keys for one or more of those groups, a guess at what the server will pick |
signature_algorithms |
Signature schemes the client can verify, e.g. ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, ed25519 |
server_name |
The hostname (SNI, RFC 6066) so the server can pick the right certificate |
application_layer_protocol_negotiation |
ALPN list such as h2, http/1.1 |
psk_key_exchange_modes |
Whether resumption may skip (EC)DHE (psk_ke) or must include it (psk_dhe_ke) |
pre_shared_key |
Session ticket identities for resumption; must be the last extension |
early_data |
Signals that 0-RTT data follows |
Browsers also add GREASE values (RFC 8701), random reserved code points that keep servers from choking on unknown values.
Key shares make the ClientHello much larger than it used to be. In a capture with OpenSSL 3.6, which sends key shares for X25519MLKEM768 and X25519 by default, the ClientHello was 1,533 bytes and the key_share extension alone was 1,258 bytes. The hybrid share is 1,184 bytes of ML-KEM-768 public key plus 32 bytes of X25519. A ClientHello that no longer fits in one TCP segment exposed some buggy middleboxes when browsers first enabled hybrid key exchange; see post-quantum TLS.
HelloRetryRequest: when the key share guess is wrong
If the server supports none of the groups the client sent key shares for, but does support another group the client listed, it replies with a HelloRetryRequest. The client then sends a second ClientHello with the requested key share, and the handshake costs an extra round trip.
On the wire, HelloRetryRequest is a ServerHello whose random field is a fixed special value (the SHA-256 hash of the string "HelloRetryRequest"). It can carry a cookie extension that the client must echo, which lets a server stay stateless. To keep the transcript manageable, the first ClientHello is replaced in the hash by a synthetic message_hash message.
Here is a real exchange against a test server that only accepts P-384, captured with OpenSSL 3.6 (-msg output, record headers removed):
>>> TLS 1.3, Handshake [length 0601], ClientHello
<<< TLS 1.3, Handshake [length 0058], ServerHello <- HelloRetryRequest
>>> TLS 1.3, Handshake [length 017e], ClientHello <- retry with secp384r1 share
<<< TLS 1.3, Handshake [length 00bb], ServerHello
<<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0193], Certificate
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Handshake [length 0034], Finished
Servers should list a group that common clients send a share for (X25519, and as of November 2026 increasingly X25519MLKEM768) to avoid this penalty.
ServerHello: the server's choice
The ServerHello fixes the cipher suite and supplies the server's key share; after it, both sides can compute the handshake secret. It contains:
cipher_suite: one TLS 1.3 suite from the client's list.supported_versions:0x0304, the actual protocol version.key_share: the server's public key for the chosen group. For X25519MLKEM768 it is 1,120 bytes, an ML-KEM ciphertext (1,088 bytes) plus an X25519 public key.pre_shared_key: which ticket was accepted, if resuming.
The server's random also carries downgrade protection. If a TLS 1.3 server ends up negotiating TLS 1.2 or lower, it sets the last eight bytes to DOWNGRD followed by 01 or 00, and a TLS 1.3 client that sees this aborts. Because the random value is covered by the server's signature, an attacker cannot strip it.
EncryptedExtensions
EncryptedExtensions is the first encrypted message and carries every negotiated extension that is not needed to compute keys. Typical contents are the ALPN protocol the server selected (h2), an empty server_name acknowledgement, the server's supported_groups preference (a hint for next time), and early_data if 0-RTT was accepted. Moving these out of ServerHello means passive observers no longer see them.
CertificateRequest (optional)
The server sends CertificateRequest only when it wants client authentication, as in mutual TLS. It contains a certificate_request_context, the signature_algorithms the server accepts, and optionally a certificate_authorities list naming acceptable issuers. TLS 1.3 also allows post-handshake client authentication if the client advertised post_handshake_auth.
Certificate
The Certificate message carries the server's chain: the end-entity certificate first, then intermediates. Each entry can have its own extensions, so the OCSP staple (status_request) and Signed Certificate Timestamps travel attached to the certificate they describe. The root is usually omitted because the client must already trust it; a missing intermediate is a classic cause of missing intermediate certificate errors. Chains can also be compressed with RFC 8879 certificate compression; Chrome, for example, negotiates Brotli compression.
CertificateVerify
CertificateVerify proves the server holds the private key for the certificate. The server signs a hash of the transcript so far, prefixed by 64 spaces and the context string TLS 1.3, server CertificateVerify. The context string prevents a signature from one protocol being reused in another. A server with an RSA key must use RSA-PSS for this signature (rsa_pss_rsae_sha256 and friends), even if the certificate itself is signed with PKCS#1 v1.5. For clients, RFC 9963 (April 2026) added rsa_pkcs1_sha256_legacy and related code points, so smart cards and other hardware that can only produce PKCS#1 v1.5 signatures can still authenticate over TLS 1.3, provided the server offers those code points in CertificateRequest.
Finished
Finished is an HMAC over the entire transcript with a key derived from the handshake traffic secret. It confirms that both sides saw exactly the same messages and derived the same keys; any tampering with the ClientHello or a downgrade attempt makes it fail. Right after its Finished the server may already send application data, sometimes called 0.5-RTT data, because the server knows its keys before it has authenticated the client.
The client's second flight
The client verifies the chain, hostname and CertificateVerify signature, checks the server's Finished, and then sends its own flight:
EndOfEarlyData, only if 0-RTT data was sent and accepted.CertificateandCertificateVerify, only if the server sent CertificateRequest. A client without a certificate sends an empty Certificate message.Finished.
Application data can follow in the same flight, so an HTTP request is on the wire one round trip after the ClientHello.
NewSessionTicket and resumption
After the handshake the server can send one or more NewSessionTicket messages. Each ticket is an opaque blob plus a lifetime (at most 604,800 seconds, 7 days), an age obfuscation value and a nonce. On the next connection the client offers the ticket in pre_shared_key. With psk_dhe_ke the resumed handshake still runs a fresh (EC)DHE exchange, preserving forward secrecy; the server can then skip Certificate and CertificateVerify entirely. See session resumption.
0-RTT early data
With a ticket that allows it, the client can send application data in its first flight, encrypted with the client early traffic key. That saves a full round trip, but early data has two weaknesses: it is not forward secret with respect to the ticket key, and an attacker can replay it to the server. The TLS 1.3 specification recommends anti-replay mechanisms, and RFC 8470 defines how HTTP handles it: servers can answer 425 Too Early, and proxies mark forwarded early data with Early-Data: 1. Only idempotent requests such as a plain GET should ever be sent as 0-RTT.
The key schedule in one picture
TLS 1.3 derives every key from three HKDF-Extract steps (RFC 5869). Simplified:
PSK (or zeros)
|
v
HKDF-Extract ----> Early Secret ----> binder key, client_early_traffic_secret
|
Derive-Secret("derived")
|
(EC)DHE ----> HKDF-Extract ----> Handshake Secret
| +--> client_handshake_traffic_secret (hash of CH..SH)
| +--> server_handshake_traffic_secret
Derive-Secret("derived")
|
zeros ---> HKDF-Extract ----> Main Secret
+--> client/server_application_traffic_secret_0
| (hash of CH..server Finished)
+--> exporter_secret
+--> resumption_secret -> ticket PSKs
Each traffic secret is bound to the transcript hash at a specific point, so keys are tied to the exact messages exchanged. KeyUpdate messages later ratchet the application secrets forward for long-lived connections.
The names follow RFC 9846, which replaced the term "master" used in RFC 8446 with "main" or shorter names: Master Secret became Main Secret, and exporter_master_secret and resumption_master_secret became exporter_secret and resumption_secret. RFC 9846 also forbids reusing key shares between connections, limits the number of KeyUpdate messages, and makes initiating a KeyUpdate before the key usage limits are reached a MUST.
TLS 1.2 versus TLS 1.3 handshakes
| TLS 1.2 (RFC 5246, now obsoleted by RFC 9846) | TLS 1.3 (RFC 8446, now RFC 9846) | |
|---|---|---|
| Round trips, full handshake | 2 | 1 (2 with HelloRetryRequest) |
| Resumption with data | 1 RTT | 0-RTT possible |
| Key exchange | RSA, DHE or ECDHE in ServerKeyExchange | (EC)DHE or hybrid in key_share; no static RSA |
| Certificate visible to observers | Yes, sent in clear | No, encrypted |
| Server proof of key | Signature over ServerKeyExchange, or RSA decryption | CertificateVerify over full transcript |
| ChangeCipherSpec | Required | Dummy only, for middleboxes |
| Renegotiation | Supported | Removed; KeyUpdate and post-handshake auth instead |
Watching a handshake with OpenSSL
openssl s_client -msg prints every handshake message. Against a test server running OpenSSL 3.6 with an ECDSA certificate, the output with record header lines removed is:
openssl s_client -connect example.com:443 -servername example.com -msg </dev/null 2>&1 | grep -E '^(<<<|>>>)'
>>> 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 0006], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0193], Certificate
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished
-trace (available when OpenSSL is built with enable-ssl-trace, as Homebrew's build is) decodes each extension:
ClientHello, Length=1533
extension_type=server_name(0), length=16
extension_type=supported_groups(10), length=18
extension_type=signature_algorithms(13), length=54
extension_type=supported_versions(43), length=5
extension_type=psk_key_exchange_modes(45), length=2
extension_type=key_share(51), length=1258
NamedGroup: X25519MLKEM768 (4588)
NamedGroup: ecdh_x25519 (29)
ServerHello, Length=1206
cipher_suite {0x13, 0x02} TLS_AES_256_GCM_SHA384
extension_type=supported_versions(43), length=2
extension_type=key_share(51), length=1124
NamedGroup: X25519MLKEM768 (4588)
To see the encrypted messages in Wireshark, add -keylogfile keys.txt and load that file as the TLS key log; browsers do the same with the SSLKEYLOGFILE environment variable. More recipes are in the connection testing cookbook.
FAQ
How many round trips does a TLS 1.3 handshake take?
One for a full handshake, two if the server has to send a HelloRetryRequest. A resumed session using 0-RTT can send application data in the very first flight. TLS 1.2 needs two round trips for a full handshake.
Is the server certificate encrypted in TLS 1.3?
Yes. The Certificate message is sent after ServerHello and is encrypted with handshake traffic keys, so a passive observer cannot read the chain. The hostname in the SNI extension is still visible unless Encrypted Client Hello (ECH) is used.
Why do I see ChangeCipherSpec in a TLS 1.3 capture?
That is middlebox compatibility mode. Implementations send a dummy ChangeCipherSpec record so the exchange looks like a TLS 1.2 session resumption to old firewalls. It has no cryptographic meaning in TLS 1.3.
What does HelloRetryRequest look like in openssl s_client -msg?
OpenSSL prints it as a second ServerHello: you see ClientHello, a short ServerHello, then a second ClientHello before the real ServerHello. The short one is the HelloRetryRequest, identified by a fixed special Random value.
Sources
- RFC 9846: TLS 1.3 (sections 2, 4 and 7), July 2026
- RFC 8446: TLS 1.3, original 2018 specification (obsoleted by RFC 9846)
- RFC 8448: Example Handshake Traces for TLS 1.3
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
- RFC 8470: Using Early Data in HTTP
- RFC 9963: Legacy RSASSA-PKCS1-v1_5 Code Points for TLS 1.3
- RFC 5246: TLS 1.2 (obsoleted by RFC 9846)
- OpenSSL documentation: openssl-s_client