# The TLS 1.3 handshake explained, message by message

> Every TLS 1.3 handshake message explained: ClientHello extensions, HelloRetryRequest, encrypted server flight, Finished, tickets and 0-RTT.

Source: https://ssl.sh/learn/tls-handshake/
Last updated: 2026-11-10

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.

```text
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](https://ssl.sh/glossary/sni/), RFC 6066) so the server can pick the right certificate |
| `application_layer_protocol_negotiation` | [ALPN](https://ssl.sh/glossary/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](https://ssl.sh/learn/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):

```text
>>> 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](https://ssl.sh/learn/mtls/). 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](https://ssl.sh/errors/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:

1. `EndOfEarlyData`, only if 0-RTT data was sent and accepted.
2. `Certificate` and `CertificateVerify`, only if the server sent CertificateRequest. A client without a certificate sends an empty Certificate message.
3. `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](https://ssl.sh/glossary/forward-secrecy/); the server can then skip Certificate and CertificateVerify entirely. See [session resumption](https://ssl.sh/glossary/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](https://ssl.sh/glossary/0-rtt/).

## The key schedule in one picture

TLS 1.3 derives every key from three HKDF-Extract steps (RFC 5869). Simplified:

```text
           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:

```bash
openssl s_client -connect example.com:443 -servername example.com -msg </dev/null 2>&1 | grep -E '^(<<<|>>>)'
```

```text
>>> 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:

```text
    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](https://ssl.sh/cookbook/test-connections/).
