skip to content

Why does a TLS 1.3 handshake still put a change_cipher_spec record and a non-empty session id on the wire?

level: middleimportance: nice to knowfreq 28%

answer

  1. two tokens that do nothing
  2. made to look like a 1.2 handshake
  3. a dummy record, discarded on receipt
  4. session id echoed back, mismatch is fatal
  5. no key change, not in the transcript

basics

~20 s

Middlebox compatibility mode. A TLS 1.3 client sends a non-empty legacy session id that the server echoes, plus a dummy change_cipher_spec record, so the exchange resembles a TLS 1.2 resumption to elements in the path. Neither changes any key.

solid answer

~50 s

Both are deliberate decoration, described by TLS 1.3 as compatibility mode. The client puts a fresh 32-byte value in `legacy_session_id` even though it is resuming nothing, and the server returns it in `legacy_session_id_echo`; the client also sends a dummy record of content type `change_cipher_spec(20)` immediately before its second flight, and the server may send one immediately after its first handshake message. To anything in the path that reads only the hellos and the content types, the result looks like the TLS 1.2 handshake shape it already passes. Inside TLS 1.3 neither token means anything: the dummy record changes no keys and advances no state, and a receiver discards it. The echo is not decoration in one respect - a client whose `legacy_session_id_echo` does not match what it sent must abort with a fatal `illegal_parameter(47)` alert.

code

pseudocode · 7 lines
pseudocode
on record with content type change_cipher_spec(20):
    if the handshake is in progress:
        discard the record
        change no keys, advance no state
        keep reading the next record
    else:
        abort the connection with a fatal alert

go deeper

for a junior

Recall that a TLS 1.3 exchange can carry leftovers from TLS 1.2 that no longer mean anything, and that seeing a session id does not mean a session is being resumed.

for a middle

Explain both tokens: a 32-byte session id echoed back verbatim, and a dummy change_cipher_spec record that is discarded, neither of which changes a key or is required.

for a senior

When reading a captured exchange, resist the TLS 1.2 landmarks: locate protection by the key schedule's points rather than by the dummy record, and treat an echo mismatch as the fatal condition it is.

for a principal

Weigh what compatibility decoration buys: deployability through paths you do not control, paid for with permanent ambiguity in the protocol's own on-wire evidence.

## The two tokens A TLS 1.3 handshake on the wire is not the minimal exchange the key schedule needs. It usually carries two extra things, both of which exist purely so the exchange resembles a TLS 1.2 handshake to whatever is sitting between the two endpoints. The specification calls this **middlebox compatibility mode**, and it is optional for a client to use. - **A non-empty session id.** A TLS 1.3 client that has no TLS 1.2 session to resume still puts a fresh 32-byte value in `legacy_session_id`. The server copies it back in `legacy_session_id_echo`. In TLS 1.2 this pair meant *we are resuming a session with this identifier*; in TLS 1.3 it identifies nothing, because a TLS 1.3 handshake resumes with a pre-shared key, not with a session id. - **A dummy record.** The client sends a record whose content type is `change_cipher_spec(20)` immediately before its second flight, and a server may send one immediately after its first handshake message. In TLS 1.2 such a record announced that the sender was switching to the newly agreed keys. In TLS 1.3 it announces nothing. ## What they are not This is where the mode is routinely misread. - The dummy record **does not change keys**. Key changes in TLS 1.3 come from the key schedule at defined points in the handshake, and later from a dedicated rekey message; the record is discarded on receipt. - The dummy record **is not part of the transcript**. It travels as its own content type rather than as a handshake message, so it is not hashed into the transcript that `Finished` covers. Tampering with it changes nothing a `Finished` check would catch - and there is nothing in it to tamper with. - The session id **is not a session**. Nothing is being resumed, no server-side state is being looked up, and the value is discarded once the handshake completes. - The session id **is** covered, because it sits inside `ClientHello` and `ServerHello`, both of which are hashed into the transcript. That is why a mismatch is worth checking at all. ## What a receiver does with each | Token | Sender | Receiver's obligation | |---|---|---| | `legacy_session_id` | client, in `ClientHello` | server echoes it verbatim in `legacy_session_id_echo` | | `legacy_session_id_echo` | server, in `ServerHello` | client compares it with what it sent; a mismatch is a fatal `illegal_parameter(47)` abort | | dummy `change_cipher_spec(20)` record | either peer, during the handshake | accept and discard it, changing no keys and advancing no state | | `change_cipher_spec(20)` outside the handshake window | either peer | treat it as an error and abort the connection | ## Why the mode exists at all TLS 1.3 was deployed into a network full of elements that had learned the *shape* of a TLS 1.2 handshake: a hello with a session id, a `change_cipher_spec` record, then opaque bytes. Exchanges that did not have that shape were in practice being dropped or stalled by some of those elements, and an endpoint cannot tell the difference between "the peer refused" and "something in the middle ate the flight". Rather than require every path to be fixed first, TLS 1.3 kept two meaningless tokens whose only job is to make the new handshake look familiar. The cost is exactly the confusion this question is about: a reader who knows TLS 1.2 sees a session id and a `change_cipher_spec` record and concludes that a session is being resumed and that keys change there. Neither is true. ## What it means in practice 1. Do not infer resumption from a non-empty session id in a TLS 1.3 exchange. Resumption in TLS 1.3 is visible as a pre-shared key offer, not as a session id. 2. Do not treat the dummy record as a landmark for where protection begins. In TLS 1.3 the handshake becomes protected at a point the key schedule defines, which is earlier than the dummy record's position suggests. 3. A handshake that carries neither token is still a perfectly valid TLS 1.3 handshake. Its absence tells you the client chose not to use compatibility mode, not that anything is wrong. 4. A mismatched echo is a real failure with a named cause: a fatal `illegal_parameter(47)`, sent by the client, on receiving a `ServerHello` that returned the wrong bytes. ## Version context Everything above applies to TLS 1.3. In TLS 1.2 both objects are load-bearing: the session id names a session a server may look up to resume, and the `change_cipher_spec` record genuinely marks the sender's switch to the agreed keys. The tokens survived into TLS 1.3 with their meaning removed and their appearance kept.

  • Is compatibility mode mandatory for a TLS 1.3 handshake?
    No. A client chooses whether to use it. A handshake with an empty `legacy_session_id` and no dummy record is a valid TLS 1.3 handshake; clients use the mode because paths that had learned the TLS 1.2 shape were observed interfering with exchanges that lacked it.
  • Is the dummy change_cipher_spec record covered by the transcript that Finished authenticates?
    No. It is its own content type rather than a handshake message, so it is never hashed into the handshake transcript. The `legacy_session_id` and its echo are covered, because they sit inside `ClientHello` and `ServerHello`, which are.

saying these in an interview costs you the question

  • Says the change_cipher_spec record still switches keys in TLS 1.3
  • Reads a non-empty session id as evidence of resumption
  • Thinks a mismatched legacy_session_id_echo can safely be ignored
  • Assumes every TLS 1.3 handshake must use compatibility mode
  • Counts the dummy record as part of the transcript Finished covers