Module 06 — The TLS Handshake, 1.2 vs 1.3

Updated 20 August 2026

Module 06 · The TLS Handshake, 1.2 vs 1.3

You have a key, a certificate, a chain and a CA. This module is about the twenty milliseconds in which all of it gets used. You will watch a real handshake message by message in both TLS 1.2 and 1.3, see exactly what 1.3 removed and why, and understand the hybrid post-quantum key exchange that now carries two thirds of browser traffic.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–05. You need symmetric/asymmetric crypto and hybrid encryption from Module 01 (Part B), the certificate fields from Module 03, and the chain from Module 05.


The picture to hold in your head for this whole module — two strangers meeting in a corridor.

They have never met, anyone might be listening, and in under a second they must agree on three things:

  1. Which language shall we speak? — protocol version and cipher suite
  2. Who are you? — the certificate and its proof
  3. What is our shared secret? — the key that encrypts everything after

And the hard part: step 3 has to happen in the open, where the eavesdropper hears every word, and they must still end up with a secret the eavesdropper does not have.

That sounds impossible. Module 01 (Part B2) already showed you why it is not.

Set up before you start. You will use the CA and server certificate you built in Module 05.
bash
mkdir -p ~/tls-lab/m06 && cd ~/tls-lab/m05     # we reuse Module 05's lab
umask 077
openssl version

# start a local TLS server in the background
nohup openssl s_server -cert app.crt -cert_chain int/int.crt -key app.key \
      -accept 4433 -www > /tmp/tls-srv.log 2>&1 &
sleep 1

Running your own server matters here. Against a real site you would be watching a handshake you cannot control; locally you can force versions, break things, and see the difference.

Stop it later with kill %1, or pgrep -f 'accept 4433' | xargs kill.

All expected output was produced on OpenSSL 3.0.13.

Part A · What a handshake is actually for

A1 · Three jobs, in order

The analogy — arriving at a stranger's front door.

Three things happen before you are let in, always in this order:

  1. You find a common language. No point continuing if you speak French and they speak Japanese.
  2. They prove who they are. They hold up ID through the window — you check it before you hand anything over.
  3. You agree a private way to talk. Only now, once you know who they are, is it worth establishing a secret.

Getting the order wrong ruins it. Agree a secret before checking the ID and you have established a private channel with a stranger — which, as Module 01 (Part C1) showed, is exactly the man-in-the-middle situation.

JobWhat is decidedWhat you have already learned
1. NegotiateTLS version, cipher suite, key exchange group, extensionsNew in this module
2. AuthenticateThe server proves it holds the private key for the certificate it sentModules 03–05 — the certificate and its chain
3. Agree a keyBoth sides derive the same symmetric key without ever sending itModule 01, B2 and B3 — hybrid encryption
The one sentence to carry through this whole module: the certificate does not encrypt anything.

In Module 01 you learned the armoured-van analogy — asymmetric crypto is slow, so you use it once and switch to symmetric. It is easy to conclude the certificate's key is used to encrypt the session key.

In TLS 1.3 that is never true, and in modern TLS 1.2 it is almost never true. The certificate's key is used only to sign, proving possession. The actual key agreement is a separate Diffie-Hellman exchange that the certificate takes no part in.

Why that matters: it is the reason stealing a server's private key does not let an attacker decrypt yesterday's traffic. That property is called forward secrecy, and it is the single biggest thing TLS 1.3 made non-optional.

🧪 Exercise A1.1 — Look at what a handshake negotiated
bash
cd ~/tls-lab/m05
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt </dev/null 2>/dev/null \
  | grep -E 'Protocol *:|Cipher *:|Server Temp Key|Peer signature|Verify return code'
Expected result — click to reveal
plain text
Peer signature type: ECDSA
Server Temp Key: X25519, 253 bits
Verify return code: 0 (ok)
    Protocol  : TLSv1.3
    Cipher    : TLS_AES_256_GCM_SHA384
    Verify return code: 0 (ok)

What to read out of this — five lines, and each is one of the three jobs.

  • Protocol : TLSv1.3 — job 1. Both sides supported it, so it was chosen. Neither side was asked; the client offers, the server picks.
  • Cipher : TLS_AES_256_GCM_SHA384 — also job 1. AES-256 in GCM mode, with SHA-384 for key derivation.
  • Peer signature type: ECDSA — job 2. The server signed part of the handshake with its certificate's private key. Note the word signature: the certificate key signed, it did not encrypt.
  • Server Temp Key: X25519, 253 bits — job 3, and the most important line here. "Temp" means ephemeral — a key pair generated for this connection and thrown away afterwards. It has nothing to do with the certificate.
  • Verify return code: 0 (ok) — the chain validated, using everything from Module 05.

🔑 Server Temp Key is the forward-secrecy indicator. If that line is present, the session key came from an ephemeral Diffie-Hellman exchange, and stealing the server's certificate key later will not decrypt this traffic. If the line is absent on a TLS 1.2 connection, you are looking at RSA key transport and there is no forward secrecy — which is section C2.

🎯 Interview questions — What a handshake does

Q. Describe the process of a TLS handshake.

Three jobs, in order: negotiate parameters, authenticate the server, agree a shared symmetric key.

In TLS 1.3 it is one round trip. The client sends a ClientHello containing its supported versions, cipher suites, and — crucially — a key share guessed in advance. The server replies with ServerHello (its own key share), and from that point everything is encrypted: EncryptedExtensions, its Certificate, a CertificateVerify signature proving it holds the matching private key, and Finished. The client verifies the chain, checks the signature, and sends its own Finished. Application data flows after 1-RTT.

In TLS 1.2 it is two round trips, because the client cannot send a key share until it learns which parameters the server chose.

The detail that separates a strong answer: the certificate's key is used only for a signature, never to encrypt the session key. Key agreement is a separate ephemeral Diffie-Hellman exchange — which is what gives forward secrecy, and why TLS 1.3 removed RSA key transport entirely.


Part B · TLS 1.3, message by message

B1 · The flow

Diagram source
sequenceDiagram
    participant C as 💻 Client
    participant S as 🌐 Server
    Note over C,S: ── in the clear ──
    C->>S: ClientHello<br>versions, cipher suites, SNI, ALPN<br>+ KEY SHARE guessed in advance
    S->>C: ServerHello<br>chosen version + cipher + its KEY SHARE
    Note over C,S: 🔐 both sides now derive keys — everything below is ENCRYPTED
    S->>C: EncryptedExtensions
    S->>C: Certificate<br>leaf + intermediates
    S->>C: CertificateVerify<br>signature over the transcript
    S->>C: Finished
    C->>S: Finished
    Note over C,S: ✅ 1-RTT — application data flows
The analogy — guessing the language before you knock.

The old way: knock, ask "which language do you speak?", wait for the answer, then start speaking it. Two trips to the door.

If you guessed wrong they say "actually, Japanese" and you try again — which costs an extra trip, but is rare, because the common languages are obvious.

That guess is the key share in the ClientHello, and it is what removes an entire round trip. The retry is called HelloRetryRequest.

🧪 Exercise B1.1 — Watch every message go past
bash
cd ~/tls-lab/m05
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -msg </dev/null 2>&1 \
  | grep -E '^(<<<|>>>)' | grep -v RecordHeader | head -12
Expected result — click to reveal
plain text
>>> TLS 1.3, Handshake [length 013a], ClientHello
<<< TLS 1.3, Handshake [length 007a], ServerHello
<<< TLS 1.3, InnerContent [length 0001]
<<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
<<< TLS 1.3, InnerContent [length 0001]
<<< TLS 1.3, Handshake [length 0982], Certificate
<<< TLS 1.3, InnerContent [length 0001]
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, InnerContent [length 0001]
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished

What to read out of this — this is the diagram above, real.

  • >>> is client→server, <<< is server→client. Count them: the client sends one message before the server's whole reply arrives. One round trip.
  • Everything from EncryptedExtensions onward is encrypted. That is why each is preceded by InnerContent — TLS 1.3 wraps encrypted handshake records so that even the type of message is hidden. The certificate itself is encrypted on the wire, which is a genuine privacy improvement over 1.2: a passive observer cannot see which certificate the server presented.
  • Certificate [length 0982] — 2,434 bytes. Look at that relative to ServerHello at 122 bytes. The certificate chain is the overwhelming bulk of a handshake, which is why chain length is a real performance figure (Module 05, A1.1) and why EC keys are worth using (Module 02, A1.1).
  • CertificateVerify [length 004f] — 79 bytes. This is job 2, and it is the only place the certificate's private key is used. A signature over the entire handshake transcript so far, proving live possession of the key.
  • ChangeCipherSpec is a fossil. TLS 1.3 does not need it — key changes are implied. It is sent purely so that middleboxes written for TLS 1.2 see something familiar and do not drop the connection. RFC 8446 calls this "compatibility mode", and it is a small monument to how much broken network equipment exists.

💡 -msg is the flag to remember. For even more detail, -trace decodes the contents of each message — verbose, but the best way to see exactly which extensions a client offered.


B2 · Key agreement, and what forward secrecy actually buys you

The analogy — mixing paint in public.

This is the classic explanation of Diffie-Hellman and it is worth walking through, because once it clicks the rest of TLS gets easier.

You and I agree publicly on a common colour — say yellow. Everyone can see it.

I privately pick red and mix it with yellow, giving orange. I send you the orange. You privately pick blue, mix, and send me green. The eavesdropper sees yellow, orange and green.

Now I add my red to your green. You add your blue to my orange. We both end up with the same muddy brown.

The eavesdropper cannot get there. They would need to un-mix orange back into red, and paint does not un-mix. That un-mixing is the hard maths problem.

Ephemeral means we both throw away our red and blue the moment we are done. Nobody — including us — can ever reproduce that brown again.

Now the consequence, which is the whole point.

An attacker records your encrypted traffic today and stores it. Two years later they steal the server's private key.

Can they decrypt the recording?

With ephemeral key exchange: no. The certificate's key never touched the session key — it only signed a handshake message. The red and blue paint were destroyed two years ago.

With old RSA key transport: yes, completely. The client encrypted the secret to the server's public key, so the private key decrypts it forever.

That difference is forward secrecy, and it is why TLS 1.3 made ephemeral key exchange the only option. It is also why "harvest now, decrypt later" is a real strategy — and why post-quantum key exchange (Part D4) matters today rather than in 2035.

🧪 Exercise B2.1 — Prove the session key is different every time
bash
cd ~/tls-lab/m05

for i in 1 2 3; do
  openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
    -CAfile root/root.crt </dev/null 2>/dev/null \
    | grep -E 'Server Temp Key|Master-Key' | head -2
  echo "---"
done
Expected result — click to reveal
plain text
Server Temp Key: X25519, 253 bits
---
Server Temp Key: X25519, 253 bits
---
Server Temp Key: X25519, 253 bits
---

What to read out of this.

  • The same group every time — X25519 — but a different key pair each time. "Temp" is the tell. The group is a negotiated parameter; the actual key is generated fresh per connection and discarded after.
  • Master-Key is empty on TLS 1.3, and that is deliberate. On TLS 1.2 OpenSSL prints the master secret here. TLS 1.3 uses a key schedule deriving many separate keys, so there is no single master secret to print. If you need to decrypt your own traffic in Wireshark you use SSLKEYLOGFILE instead, not this field.
  • X25519 is a Montgomery curve, not one of the NIST P-curves from Module 02. It was chosen for TLS because it is fast and its implementation avoids several classes of side-channel bug that plagued P-256. It is the default group in essentially every modern client.

💡 253 bits looks odd next to X25519's name. The curve uses 255-bit arithmetic and the usable scalar range works out to 253 bits. It is not a weakness and not a typo — it is just an honest count.

🎯 Interview questions — Key exchange and forward secrecy

Q. What is forward secrecy and why does it matter?

The property that compromising a server's long-term private key does not allow decryption of past sessions.

It comes from using ephemeral Diffie-Hellman (ECDHE) for key agreement: both sides generate throwaway key pairs per connection, derive a shared secret, and destroy the private halves. The certificate's key is used only to sign the handshake, proving identity — it never carries the session key.

Without it — old RSA key transport, where the client encrypted the pre-master secret to the server's public key — anyone who later obtains that private key can decrypt every recorded session, retroactively and completely.

Why it is not theoretical: "harvest now, decrypt later" is a real adversary model. Traffic recorded today can be decrypted whenever the key is obtained, whether by compromise, legal compulsion, or eventually a quantum computer. TLS 1.3 removed all non-forward-secret key exchange for exactly this reason.

The operational check: openssl s_client ... | grep 'Server Temp Key'. If that line is present you have forward secrecy; if it is absent on a TLS 1.2 connection, you do not.


B3 · Cipher suites in TLS 1.3 — five, not three hundred

The analogy — the restaurant that cut its menu.

The old menu had 340 dishes. Most were terrible, a few were actively poisonous, and choosing well required expertise most diners did not have. Every guide to "configuring TLS" was really a guide to reading that menu.

The new menu has five dishes, and the kitchen removed everything it would not serve to its own family.

That is TLS 1.3. You cannot misconfigure it into insecurity, because the insecure options were deleted rather than deprecated.

TLS 1.3 cipher suiteWhen you would see it
TLS_AES_128_GCM_SHA256The mandatory one. Every implementation must support it
TLS_AES_256_GCM_SHA384Commonly preferred by servers. What your lab negotiated
TLS_CHACHA20_POLY1305_SHA256Preferred by mobile clients with no AES hardware acceleration
TLS_AES_128_CCM_SHA256Constrained/IoT devices. Rarely enabled
TLS_AES_128_CCM_8_SHA256Same, with a truncated tag. Rarely enabled
🧪 Exercise B3.1 — See how few options your build actually offers
bash
openssl ciphers -s -tls1_3 -v
echo "--- versus TLS 1.2 ---"
openssl ciphers -s -tls1_2 | tr ':' '\n' | wc -l
openssl ciphers -s -tls1_2 -v 'ECDHE+AESGCM' | head -4
Expected result — click to reveal
plain text
TLS_AES_256_GCM_SHA384         TLSv1.3 Kx=any  Au=any  Enc=AESGCM(256)            Mac=AEAD
TLS_CHACHA20_POLY1305_SHA256   TLSv1.3 Kx=any  Au=any  Enc=CHACHA20/POLY1305(256) Mac=AEAD
TLS_AES_128_GCM_SHA256         TLSv1.3 Kx=any  Au=any  Enc=AESGCM(128)            Mac=AEAD

--- versus TLS 1.2 ---
45
ECDHE-ECDSA-AES256-GCM-SHA384  TLSv1.2 Kx=ECDH  Au=ECDSA Enc=AESGCM(256) Mac=AEAD
ECDHE-RSA-AES256-GCM-SHA384    TLSv1.2 Kx=ECDH  Au=RSA   Enc=AESGCM(256) Mac=AEAD
ECDHE-ECDSA-AES128-GCM-SHA256  TLSv1.2 Kx=ECDH  Au=ECDSA Enc=AESGCM(128) Mac=AEAD
ECDHE-RSA-AES128-GCM-SHA256    TLSv1.2 Kx=ECDH  Au=RSA   Enc=AESGCM(128) Mac=AEAD

What to read out of this — the Kx and Au columns are the entire story.

  • Three TLS 1.3 suites, forty-five TLS 1.2 suites on the same build — and that is already a hardened default list. Historically over 300 were defined.
  • Kx=any Au=any on every 1.3 suite. Key exchange and authentication were removed from the cipher suite in TLS 1.3 and negotiated separately. A 1.3 suite names only the bulk cipher and the hash.
  • Kx=ECDH Au=ECDSA on the 1.2 suites. In 1.2 the suite bundles four decisions into one name, which is why ECDHE-ECDSA-AES256-GCM-SHA384 is such a mouthful — and why the same cipher appears four times with different key exchange and authentication combinations.
  • Mac=AEAD on all of them. Every suite listed is authenticated encryption — encryption and integrity in one operation. TLS 1.3 permits nothing else, which eliminates the entire family of padding-oracle attacks (BEAST, Lucky13, POODLE) that plagued the older CBC suites.
  • Only three of the five 1.3 suites appear. The two CCM ones are compiled out or disabled by default here — normal for a general-purpose build; they matter only for constrained devices.

🔑 The practical consequence for your job: TLS 1.3 configuration is nearly a non-decision. You cannot pick a bad suite because none is offered. Cipher configuration only matters for the TLS 1.2 you still have to support — and there the right move is not to hand-craft a list but to copy a maintained one from TLSRef (which is where Mozilla's Server Side TLS guidance moved to), matching the compatibility level you actually need.

🎯 Interview questions — Cipher suites

Q. Read me this cipher suite: ECDHE-RSA-AES128-GCM-SHA256.

Four separate decisions in one name:

  • ECDHE — key exchange: Elliptic Curve Diffie-Hellman, Ephemeral. The final E is the important letter: it means forward secrecy.
  • RSA — authentication: the server's certificate holds an RSA key, used to sign the handshake.
  • AES128-GCM — bulk encryption: AES with a 128-bit key in Galois/Counter Mode, which is AEAD, so it authenticates as well as encrypts.
  • SHA256 — the hash used for key derivation (and for the MAC in non-AEAD suites).

The contrast worth drawing: the TLS 1.3 equivalent is just TLS_AES_128_GCM_SHA256. Key exchange and authentication were removed from the suite name and negotiated independently, which is why 1.3 has five suites where 1.2 had hundreds.

And the practical read: if a suite name does not start with ECDHE or DHE, it has no forward secrecy. Plain AES128-GCM-SHA256 means RSA key transport, and should not be enabled in 2026.

Q. How would you choose a cipher suite configuration for a web server?

I would not hand-craft one. I would take a maintained configuration from TLSRef — the successor to Mozilla's Server Side TLS guidance — and pick the compatibility profile the business actually needs: Modern (TLS 1.3 only) if every client is current, Intermediate otherwise.

The reasoning: hand-written cipher strings age badly. A list that was excellent in 2018 permits things that are now considered weak, and nobody revisits it. Pointing at a maintained source means the decision gets re-made by people who track it.

The specifics I would still check myself: TLS 1.0 and 1.1 disabled, no non-forward-secret suites, no CBC suites if TLS 1.2 clients allow it, and server cipher preference disabled for TLS 1.3 (it does not apply) but considered for 1.2.

And the honest framing: for TLS 1.3 this is nearly a non-decision — all five suites are safe. Cipher configuration is really about the TLS 1.2 you still have to support, and the best long-term fix is to reduce the population that needs it.


Part C · TLS 1.2, and what 1.3 changed

C1 · The 1.2 flow, and the extra round trip

Diagram source
sequenceDiagram
    participant C as 💻 Client
    participant S as 🌐 Server
    Note over C,S: ── ROUND TRIP 1 ──
    C->>S: ClientHello<br>versions, cipher suites, SNI<br>NO key share
    S->>C: ServerHello — chosen cipher
    S->>C: Certificate
    S->>C: ServerKeyExchange<br>its DH share, signed
    S->>C: ServerHelloDone
    Note over C,S: ── ROUND TRIP 2 ──
    C->>S: ClientKeyExchange — its DH share
    C->>S: ChangeCipherSpec
    C->>S: Finished 🔐
    S->>C: NewSessionTicket
    S->>C: ChangeCipherSpec
    S->>C: Finished 🔐
    Note over C,S: ✅ 2-RTT — application data flows
The analogy — asking before guessing.

TLS 1.2 knocks and asks "which language do you speak?", waits for the answer, and only then starts speaking it. Two trips to the door instead of one.

It is not a stupid design — it is the obvious one. You cannot send your half of a secret until you know which system of secrets you are using.

TLS 1.3's insight was that the answer is almost always predictable, so guess, and pay a penalty only in the rare case you guessed wrong.

🧪 Exercise C1.1 — Watch the same server speak 1.2, and count the messages
bash
cd ~/tls-lab/m05
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -tls1_2 -msg </dev/null 2>&1 \
  | grep -E '^(<<<|>>>)' | grep -v RecordHeader | head -12
Expected result — click to reveal
plain text
>>> TLS 1.2, Handshake [length 00d1], ClientHello
<<< TLS 1.2, Handshake [length 0041], ServerHello
<<< TLS 1.2, Handshake [length 097d], 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 00ba], NewSessionTicket
<<< TLS 1.2, ChangeCipherSpec [length 0001]
<<< TLS 1.2, Handshake [length 0010], Finished

What to read out of this — compare it line by line with Exercise B1.1.

  • Count the direction changes. >>><<<>>><<<. That is two round trips. TLS 1.3 had one. On a connection with 100 ms latency, that is 100 ms saved on every new connection, before any data moves.
  • No InnerContent markers, and no encryption until ChangeCipherSpec. Everything above that line is in the clear — including Certificate. A passive observer on a TLS 1.2 connection can see exactly which certificate the server presented; on 1.3 they cannot.
  • ServerKeyExchange is the message TLS 1.3 deleted. In 1.2 the server has to send its ephemeral DH share as a separate signed message, because it did not know the cipher suite when the ClientHello arrived. In 1.3 the share rides along inside ServerHello.
  • ServerHelloDone [length 0004] — four bytes whose entire purpose is to say "I have finished talking". TLS 1.3 removed it; the structure makes it unnecessary.
  • Certificate [length 097d] = 2,429 bytes, versus 2,434 in the 1.3 run. Essentially identical — the same certificate. The difference is not size, it is that 1.3 encrypts it.
  • ChangeCipherSpec is real here. In TLS 1.3 (Exercise B1.1) the same message appears but is a compatibility fossil. Here it genuinely signals the switch to encryption.

🔑 The summary worth being able to give: "TLS 1.3 is one round trip instead of two, encrypts the certificate, and deletes ServerKeyExchange and ServerHelloDone. It gets there by having the client guess the key exchange group in advance."

🧪 Exercise C1.2 — Try to speak a version nobody supports any more
bash
cd ~/tls-lab/m05
openssl s_client -connect 127.0.0.1:4433 -tls1_1 </dev/null 2>&1 | head -3
echo "exit code: $?"
openssl s_client -connect 127.0.0.1:4433 -tls1 </dev/null 2>&1 | head -2
Expected result — click to reveal
plain text
40A7D24DF97F0000:error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available:../ssl/statem/statem_lib.c:104:
CONNECTED(00000003)
---
exit code: 1

What to read out of this.

  • no protocols available came from your own client, not from the server. Look at the ordering: the error appears before CONNECTED. OpenSSL refused to even construct a ClientHello — no packet was sent.
  • That is a distribution-level policy, not a protocol one. Debian and Ubuntu ship OpenSSL with a minimum security level that excludes TLS 1.0 and 1.1 entirely. RHEL does the same through crypto-policies. The code still exists; it is switched off.
  • This matters when you are asked to test a legacy device. You will hit this wall before you reach the device, and the fix is a client-side override such as -cipher 'DEFAULT@SECLEVEL=0' -tls1_1, not a change on the server. Knowing that the wall is on your side saves a confusing hour.

💡 TLS 1.0 and 1.1 were formally deprecated by RFC 8996 in March 2021, and browsers removed them in 2020. If a system genuinely requires them, that is a finding to raise rather than a configuration to accommodate.


C2 · RSA key transport — the thing TLS 1.3 deleted

The analogy — the padlock in the post, revisited.

Module 01 taught the letterbox: anyone can post through your slot, only you can open the door.

Old TLS used it literally. The client generated the session secret, put it through the server's letterbox — encrypted it with the certificate's public key — and posted it. Simple, obvious, and it worked for twenty years.

The flaw is equally obvious once you see it: anyone who ever obtains the door key can open every letter ever posted, including the ones they photographed going in years ago.

Ephemeral Diffie-Hellman replaces posting a secret with jointly inventing one. Nothing secret is ever posted, so there is nothing to open later.

RSA key transport (removed)ECDHE (the only option in 1.3)
Who makes the secretThe client, aloneBoth sides jointly
How it travelsEncrypted to the certificate's public keyIt never travels at all
Certificate key used forDecrypting the secretSigning only
Key Usage neededkeyEnciphermentdigitalSignature
Forward secrecyNoneYes
Steal the key in 2028Decrypts traffic recorded in 2024Decrypts nothing
This closes a loop from Module 03. In Part C3 you saw a modern certificate whose Key Usage was Digital Signature and nothing else, and the note said keyEncipherment was for a key exchange TLS 1.3 removed.

This is that key exchange. The keyEncipherment bit existed so the certificate's key could decrypt the pre-master secret. With key transport gone, the bit has no purpose on a TLS 1.3 server certificate — and an ECDSA certificate never needed it at all, because ECDSA cannot encrypt anything.

Why it was removed rather than merely discouraged. Beyond forward secrecy, RSA key transport in TLS used PKCS#1 v1.5 padding, which has been the source of a twenty-five-year run of Bleichenbacher oracle attacks — ROBOT in 2017 being the best-known revival. Each round produced patched implementations; each round something new turned up.

TLS 1.3 took the view that a construction which keeps producing the same class of vulnerability should be deleted, not defended. That is a good general principle to be able to articulate.

🧪 Exercise C2.1 — Confirm your server refuses non-forward-secret suites
bash
cd ~/tls-lab/m05

echo "=== what does a normal TLS 1.2 connection negotiate? ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -tls1_2 </dev/null 2>/dev/null \
  | grep -E 'Cipher *:|Server Temp Key'

echo
echo "=== now demand a non-forward-secret RSA suite ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -tls1_2 -cipher 'AES256-SHA' </dev/null 2>&1 | grep -E 'error|alert|Cipher *:' | head -3
Expected result — click to reveal
plain text
=== what does a normal TLS 1.2 connection negotiate? ===
Server Temp Key: X25519, 253 bits
    Cipher    : ECDHE-ECDSA-AES256-GCM-SHA384

=== now demand a non-forward-secret RSA suite ===
40E7A1B2C87F0000:error:0A000410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure

What to read out of this.

  • The normal connection has Server Temp Key — forward secrecy, from ECDHE. And note the cipher name starts with ECDHE, which is the quick visual check.
  • AES256-SHA has no ECDHE or DHE prefix. That is RSA key transport: no forward secrecy, and CBC mode rather than AEAD.
  • The handshake failed because our certificate holds an EC key, and RSA key transport requires an RSA key. So this server structurally cannot do it — an accidental but real benefit of choosing EC (Module 02, A1).
  • sslv3 alert handshake failure is the generic "we could not agree" alert. It says nothing about why, which is why cipher mismatches are frustrating to debug: you must compare both sides' lists yourself.

🔑 The three-second forward-secrecy check on any server:

bash
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null | grep 'Server Temp Key'

Line present → forward secrecy. Line absent on TLS 1.2 → RSA key transport, and a finding worth raising. On TLS 1.3 the line is always present, because there is no alternative.

🎯 Interview questions — What changed in 1.3

Q. What are the main differences between TLS 1.2 and TLS 1.3?

Five that matter, roughly in order of impact:

  1. One round trip instead of two. The client guesses the key exchange group and sends its key share in the ClientHello. A wrong guess costs a HelloRetryRequest, which is rare.
  2. Forward secrecy is mandatory. RSA key transport and static Diffie-Hellman were removed entirely, so every session gets ephemeral key agreement.
  3. The menu was cut from hundreds of cipher suites to five, all AEAD. CBC modes, RC4, 3DES, export ciphers, MD5 and SHA-1 signatures, and compression are all gone — which eliminates BEAST, Lucky13, POODLE, CRIME and the rest as a category.
  4. More of the handshake is encrypted, including the server's Certificate message. A passive observer can no longer see which certificate was presented.
  5. Cipher suites no longer encode key exchange or authentication — those are negotiated separately, which is why TLS_AES_128_GCM_SHA256 is so much shorter than ECDHE-RSA-AES128-GCM-SHA256.

The detail that shows you have looked at a real handshake: 1.3 still sends ChangeCipherSpec, which it does not need, purely so middleboxes written for 1.2 do not drop the connection. RFC 8446 calls it compatibility mode.

And the operational note: 1.3 also added 0-RTT resumption, which is genuinely faster and carries a replay risk — so it should only be enabled for idempotent requests.

Q. Why did TLS 1.3 remove RSA key exchange?

Two reasons, and the first is the important one.

No forward secrecy. With RSA key transport the client encrypts the pre-master secret to the server's public key, so anyone who later obtains that private key can decrypt every recorded session retroactively. That makes "harvest now, decrypt later" a viable strategy — record traffic today, obtain the key by compromise or compulsion at leisure.

A twenty-five-year history of padding oracles. RSA key transport in TLS used PKCS#1 v1.5, the source of repeated Bleichenbacher attacks from 1998 through ROBOT in 2017. Each round was patched; each round something new appeared. TLS 1.3 concluded that a construction which keeps producing the same class of vulnerability should be deleted rather than defended.

The visible consequence: a TLS 1.3 server certificate needs only digitalSignature in Key Usage. keyEncipherment existed to let the certificate key decrypt the pre-master secret, and has no purpose once key transport is gone.


Part D · Extensions and modern features

D1 · SNI — many sites, one IP address

The analogy — the office building with one reception desk.

Two hundred companies share one address. You walk in and say "I'm here for Acme Ltd" — and only then can reception fetch the right person.

Without that sentence, reception has one street address and no idea which of two hundred companies you want.

SNI is that sentence, and here is the awkward part: you have to say it before the private conversation starts, out loud, in the lobby. Everyone waiting there hears which company you are visiting.

SNI is sent in the clear, even in TLS 1.3.

TLS 1.3 encrypts the certificate, so an observer cannot see which certificate the server returned. But the client's ClientHello — including the hostname it asked for — is the very first message, sent before any keys exist.

So a passive observer still learns which site you visited. That is why Encrypted Client Hello (ECH) exists, and it is the last significant piece of metadata leakage in TLS.

🧪 Exercise D1.1 — See what happens when the client forgets to say who it wants
bash
cd ~/tls-lab/m05

echo "=== WITH -servername ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt </dev/null 2>/dev/null \
  | openssl x509 -noout -subject 2>/dev/null

echo "=== WITHOUT it ==="
openssl s_client -connect 127.0.0.1:4433 \
  -CAfile root/root.crt </dev/null 2>/dev/null \
  | openssl x509 -noout -subject 2>/dev/null

echo "=== is the hostname visible on the wire? ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test -trace </dev/null 2>&1 \
  | grep -iA2 'server_name' | head -6
Expected result — click to reveal
plain text
=== WITH -servername ===
subject=C = MY, O = Zaeem Labs, CN = app.internal.test

=== WITHOUT it ===
subject=C = MY, O = Zaeem Labs, CN = app.internal.test

=== is the hostname visible on the wire? ===
extension_type=server_name(0), length=22
    0000 - 00 14 00 00 11 61 70 70-2e 69 6e 74 65 72 6e 61   .....app.interna
    0010 - 6c 2e 74 65 73 74                                 l.test

What to read out of this.

  • Both returned the same certificate here, because our lab server hosts only one. That is the honest result and it is worth noticing: on a single-site server, SNI changes nothing.
  • On a multi-site server it changes everything. Omit -servername and you get whichever certificate the default virtual host serves — usually the first one defined, and usually the wrong one. This is the single most common cause of "the wrong certificate is being served".
  • Look at the hex dump. 61 70 70 2e 69 6e 74 65 72 6e 61 6c 2e 74 65 73 74 is app.internal.test in ASCII, in plaintext, in the first packet. Nothing is encrypted yet. That is SNI's privacy cost, in bytes you can read.

🔑 The diagnostic this gives you. When someone reports the wrong certificate being served, compare with and without -servername:

bash
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect host:443                  </dev/null 2>/dev/null | openssl x509 -noout -subject

If they differ, it is a virtual-host or SNI configuration problem, not a certificate problem — and the fix is in the web server config, not at the CA. If a client is failing, check whether it sends SNI at all; some very old and embedded clients do not.


D2 · ALPN — which protocol, decided during the handshake

The analogy — settling the format while you are still shaking hands.

You could get through reception, sit down, and then discover you both wanted to conduct the meeting in writing rather than aloud — and start again.

Or you could settle it during the handshake itself, at no extra cost.

ALPN is that. The client lists the application protocols it speaks inside the ClientHello, the server picks one in its reply. HTTP/2 exists in usable form because of this: without ALPN, negotiating it would have cost an extra round trip on every connection.

🧪 Exercise D2.1 — Negotiate a protocol
bash
cd ~/tls-lab/m05

echo "=== ask for HTTP/2, fall back to HTTP/1.1 ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -alpn h2,http/1.1 </dev/null 2>/dev/null | grep -i alpn

echo "=== ask for something nobody supports ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -alpn imaginary/9 </dev/null 2>/dev/null | grep -i alpn
Expected result — click to reveal
plain text
=== ask for HTTP/2, fall back to HTTP/1.1 ===
No ALPN negotiated

=== ask for something nobody supports ===
No ALPN negotiated

What to read out of this — and "No ALPN negotiated" is the correct result here.

  • openssl s_server does not speak HTTP/2, so it has nothing to agree to and declines to pick. That is ALPN working properly: no common protocol, no selection, and the connection continues on whatever the application assumes.
  • Against a real HTTP/2 site you would see ALPN protocol: h2. Try it: openssl s_client -connect www.google.com:443 -servername www.google.com -alpn h2,http/1.1 </dev/null 2>/dev/null | grep -i alpn
  • The protocol names are exact strings from an IANA registry: h2 for HTTP/2, http/1.1, h3 for HTTP/3 over QUIC, acme-tls/1 for the TLS-ALPN-01 certificate challenge you will meet in Module 10.

💡 Where ALPN bites in operations: a load balancer that advertises h2 to clients but speaks only HTTP/1.1 to the backend, or a proxy that strips the extension. The symptom is protocol errors that look like application bugs. -alpn on s_client tells you what each hop actually agreed to.


D3 · Session resumption, and the 0-RTT replay problem

The analogy — the members' pass.

The first time you visit, reception checks your ID properly. On the way out they hand you a pass. Next time you flash the pass and walk straight through — no ID check, no waiting.

0-RTT goes one step further: you shout your order through the door as you flash the pass, before anyone has confirmed anything.

Which is wonderfully fast, and has an obvious flaw. Someone standing outside can record you shouting and shout the same thing later. Reception cannot tell the difference.

If your order was "show me the menu", a repeat is harmless. If it was "transfer £5,000", it is not.

🧪 Exercise D3.1 — Watch a session being resumed
bash
cd ~/tls-lab/m05
pgrep -f 'accept 4433' | xargs -r kill; sleep 1
nohup openssl s_server -cert app.crt -cert_chain int/int.crt -key app.key \
      -accept 4433 -naccept 10 > /tmp/tls-srv.log 2>&1 &
sleep 1

echo "=== TLS 1.2: one full handshake, then five resumptions ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt -tls1_2 -reconnect </dev/null 2>/dev/null | grep -iE '^(New|Reused)'
Expected result — click to reveal
plain text
New, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reused, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reused, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reused, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reused, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reused, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384

What to read out of this.

  • One New, five Reused. Only the first connection did a full handshake. The other five skipped the certificate entirely.
  • The saving is large. A resumed handshake sends no Certificate message — that is the 2,400-byte message from Exercise B1.1 gone, plus the signature verification and the chain validation. On a page pulling fifty resources, this is most of the TLS cost.
  • The cipher stayed the same. Resumption reuses the negotiated parameters; it does not renegotiate them.

⚠️ Now try the same thing on TLS 1.3 and it will report New every time. That is not a bug and it is worth understanding, because it will confuse you otherwise.

In TLS 1.2 the server offers a session ID or ticket during the handshake. In TLS 1.3 the ticket arrives in a NewSessionTicket message after the handshake completes — so a client that connects and immediately disconnects (which is what </dev/null does) never receives one. -reconnect was designed for the TLS 1.2 model and does not demonstrate 1.3 resumption reliably.

🔑 The operational version of that same fact: in TLS 1.3, resumption state is established after the first connection is useful. A client making exactly one short-lived connection never benefits. This is why resumption helps browsers enormously and helps one-shot health-check scripts not at all.

0-RTT in one paragraph, and the rule that follows.

With a resumption ticket, TLS 1.3 lets the client send application data in its very first packet, before the handshake finishes. Zero round trips. It is the fastest thing in TLS.

It also cannot be protected against replay. There is no shared state yet, so the server cannot tell a genuine first packet from a recorded copy sent again an hour later. RFC 8446 devotes a whole appendix to this and stops short of forbidding it.

The rule: 0-RTT is only safe for idempotent requests. A GET is fine. A POST that moves money is not. In practice: enable it at your CDN for static content, and make sure your application layer never processes a state-changing request that arrived as early data. nginx exposes this as the $ssl_early_data variable, and the correct handling is to reject or replay-protect such requests explicitly.

🎯 Interview questions — Resumption and 0-RTT

Q. What is session resumption and why does it matter?

A way to skip the expensive part of a handshake on subsequent connections. The server issues a ticket (or, in TLS 1.2, a session ID) after a full handshake; the client presents it next time, and both sides resume from the previously established secret.

The saving is substantial: no Certificate message — typically 2–4 KB — no signature verification, and no chain validation. On a page loading many resources over separate connections, this is most of the TLS cost.

The version difference worth knowing: TLS 1.2 establishes the session during the handshake, so -reconnect demonstrates it easily. TLS 1.3 sends the ticket in a NewSessionTicket message after the handshake completes, so a client that connects and immediately closes never gets one. That surprises people testing resumption on 1.3.

The operational caveat: tickets are encrypted with a session ticket key held by the server. If that key is long-lived and never rotated, it becomes a forward-secrecy hole — anyone obtaining it can decrypt every resumed session. Ticket keys should rotate frequently, and must be shared correctly across a load-balanced fleet or resumption silently stops working.

Q. What is 0-RTT and what is wrong with it?

TLS 1.3 lets a resuming client send application data in its first packet, before the handshake completes — zero round trips of latency.

The problem is replay. Early data is sent before any live exchange with the server, so there is no shared state to bind it to this connection. An attacker who records that first packet can send it again later and the server has no reliable way to distinguish it. RFC 8446 Appendix E.5 documents this and deliberately does not forbid 0-RTT; it pushes the responsibility to the application.

The practical rule: only idempotent requests. GET for static content is fine; anything that changes state is not. In nginx, $ssl_early_data tells you a request arrived as early data, and the standard pattern is to return 425 Too Early for anything non-idempotent.

What this signals in an interview: being willing to say "this feature is a performance win with a correctness hazard, and here is where I would and would not enable it" is worth more than either enthusiasm or blanket refusal.


D4 · Post-quantum key exchange — the change happening right now

The analogy — the letters someone is keeping for later.

Somebody is photographing your sealed letters as they pass through the post. They cannot open them. They keep them anyway.

They are betting that in ten years they will own a machine that opens any seal made with today's technology. The letters are worthless to them now and valuable then.

That is "harvest now, decrypt later", and it is why post-quantum key exchange is being deployed in 2026 rather than when quantum computers arrive. Anything you send today that must stay secret for a decade is already at risk — not from a future attack on a future connection, but from a recording made today.

Note carefully what this argument does not apply to: signatures. A signature only has to resist forgery while the certificate is valid, which is now measured in months. Key exchange protects secrets for decades. That asymmetry is why key exchange went post-quantum first.

Why "hybrid", and what the name means. The deployed group is X25519MLKEM768, and it is exactly what the name suggests: both key exchanges run, and their outputs are combined.
  • X25519 — the classical elliptic-curve exchange from Part B2.
  • ML-KEM-768 — the NIST post-quantum standard (FIPS 203), formerly known as Kyber.

The session key derives from both, so it is secure if either holds. If ML-KEM turns out to have a flaw — it is young, and young cryptography is regularly broken — you still have X25519. If a quantum computer breaks X25519, you still have ML-KEM.

Hybrid is not a transitional compromise so much as a hedge against being wrong about new mathematics.

Where it stands in 2026Detail
Chrome / EdgeOn by default since version 131 (Nov 2024). Chrome 138 removed the ability to disable it
FirefoxIncreasingly on by default; toggled via security.tls.enable_kyber
SafariRolling out across macOS and iOS builds through 2026
OpenSSLML-KEM, ML-DSA and SLH-DSA from 3.5; X25519MLKEM768 is the default group
AdoptionCloudflare: PQ-capable client traffic passed 50% in Oct 2025, 60% in Feb 2026, two thirds by April 2026 — from under 3% in early 2024
Server sideMuch lower — roughly 10% of origins, mostly because CDNs enabled it and origins have not
Standards statusStill an IETF draft (draft-ietf-tls-ecdhe-mlkem), not yet an RFC
That last row is worth pausing on. Two thirds of human browser traffic is using a key exchange that is still a draft. That is unusual, and it tells you something about how urgent the "harvest now" problem is considered to be.

It is also a good thing to know precisely. Saying "X25519MLKEM768, still an IETF draft, default in Chrome since 131" is more convincing than "they've added quantum stuff to TLS".

🧪 Exercise D4.1 — Find out whether your build can do it
bash
openssl version

echo "=== does this build know ML-KEM? ==="
openssl list -kem-algorithms 2>/dev/null | head -5 \
  || echo "(no -kem-algorithms subcommand - this build predates OpenSSL 3.5)"

echo "=== which groups can this client offer? ==="
openssl s_client -help 2>&1 | grep -A1 'groups'

echo "=== ask a real server for a PQ group (needs OpenSSL 3.5+) ==="
openssl s_client -connect cloudflare.com:443 -servername cloudflare.com \
  -groups X25519MLKEM768 </dev/null 2>&1 | grep -E 'Server Temp Key|error' | head -2
Expected result — click to reveal

On OpenSSL 3.0.x (what most distributions still ship in 2026):

plain text
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)

=== does this build know ML-KEM? ===
(no -kem-algorithms subcommand - this build predates OpenSSL 3.5)

=== which groups can this client offer? ===
 -groups val                 Specify supported groups (ECDHE groups)

=== ask a real server for a PQ group ===
40C7B1E2A87F0000:error:0A00018E:SSL routines:...:no suitable signature algorithm

On OpenSSL 3.5+:

plain text
Server Temp Key: X25519MLKEM768, 256 bits

What to read out of this.

  • Your distribution's OpenSSL is probably too old, and that is the honest state of things in 2026. Ubuntu 24.04 ships 3.0.x; RHEL 9 ships 3.0.x or 3.2. Post-quantum support in browsers ran well ahead of post-quantum support in server distributions, which is exactly why origin-side adoption sits around 10% while client-side is at two thirds.
  • Server Temp Key: X25519MLKEM768, 256 bits is what success looks like — the same field you have been reading since Exercise A1.1, now naming a hybrid group.
  • You do not need to do anything to your certificates. This is a key exchange change, not a signature change. Your existing RSA or EC certificate works unmodified. Post-quantum signatures (ML-DSA) are a separate and much slower migration, because they require new certificates, new CA hierarchies, and root programme approval.

🔑 The distinction to be crisp about in an interview: "Post-quantum key exchange is deployed now and needs no certificate changes — it protects against harvest-now-decrypt-later. Post-quantum signatures are a much bigger migration and are not urgent, because a signature only needs to resist forgery during the certificate's lifetime, which is now measured in months."

🎯 Interview questions — Post-quantum TLS

Q. What is post-quantum TLS and is it something you need to worry about yet?

It is here already for key exchange. The deployed mechanism is X25519MLKEM768, a hybrid combining classical X25519 with ML-KEM-768 (NIST FIPS 203). Both run, and the session key derives from both, so it holds if either does.

It is on by default in Chrome and Edge since version 131 (November 2024), and by 2026 Cloudflare reports roughly two thirds of human-generated TLS traffic using it — up from under 3% in early 2024. Notably it is still an IETF draft, which is unusual at that scale.

Yes, it matters now, because of harvest now, decrypt later: an adversary recording traffic today can decrypt it whenever the capability arrives. Anything with a decade-long secrecy requirement is already exposed.

The distinction that shows real understanding: this is a key exchange change and needs no certificate changes at all — your existing RSA or EC certificate is unaffected. Post-quantum signatures (ML-DSA) are a separate, much larger migration requiring new certificates and CA hierarchies, and they are less urgent, because a signature only needs to resist forgery during the certificate's lifetime — which is heading toward 47 days.

The practical gap to name: browsers moved faster than server distributions. OpenSSL gained ML-KEM in 3.5, but most distributions still ship 3.0.x, which is why origin-side support is around 10% while client support is at two thirds.


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    CH["📤 ClientHello<br>versions · cipher suites<br>SNI · ALPN · key share"]
    CH --> N["1️⃣ NEGOTIATE<br>version + cipher + group"]
    N --> A["2️⃣ AUTHENTICATE<br>Certificate + CertificateVerify"]
    A --> K["3️⃣ AGREE A KEY<br>ephemeral ECDHE<br>certificate NOT involved"]
    K --> APP["🔒 Application data<br>AEAD encrypted"]
    A -.->|"chain validation<br>Modules 03 & 05"| CHAIN["⛓️ leaf → intermediate → root"]
    K -.->|"gives"| FS["🛡️ FORWARD SECRECY<br>steal the key later,<br>decrypt nothing"]
    APP -.->|"next time"| R["🎫 Resumption<br>skip the certificate<br>0-RTT = replayable"]
    style N fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style A fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style K fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style FS fill:#d5e8d4,stroke:#82b366,stroke-width:2px

Three jobs, in that order, every time. Everything else in this module is a variation: TLS 1.2 does the same three jobs in two round trips instead of one, resumption skips job 2, 0-RTT starts sending before job 3 finishes, and post-quantum changes only the mathematics inside job 3.

The dotted line on the right is the one to remember. Job 2 and job 3 are separate, and keeping them separate is what gives forward secrecy. Old TLS merged them — the certificate's key carried the secret — and that is precisely what was removed.


E2 · Production practice

HabitWhy
TLS 1.2 as the floor, 1.3 preferred. 1.0 and 1.1 disabledRFC 8996 deprecated 1.0/1.1 in 2021 and browsers dropped them in 2020. Anything needing them is a finding, not a config
Check Server Temp Key is present on every TLS 1.2 endpointIts absence means RSA key transport and no forward secrecy — recorded traffic is decryptable if the key ever leaks
Take cipher configuration from TLSRef, do not hand-write itHand-written cipher strings age badly and nobody revisits them. TLS 1.3 needs no tuning at all
Rotate TLS session-ticket keys frequently, and share them correctly across a fleetA long-lived ticket key is a forward-secrecy hole; a mismatched one silently disables resumption behind a load balancer
Enable 0-RTT only for idempotent requests, and handle $ssl_early_data explicitlyEarly data is replayable by design. Return 425 Too Early for anything that changes state
Always test with -servername, and once withoutOmitting SNI shows you what the default vhost serves — the usual cause of "the wrong certificate"
Prefer EC certificates: smaller Certificate message, cheaper signingThe Certificate message is ~2.4 KB and is the bulk of every full handshake
Plan an OpenSSL 3.5+ path for post-quantum key exchange on originsClients are at two thirds adoption; origins at roughly 10%, purely because distributions ship 3.0.x
Do not disable TLS 1.3 to "fix" a middleboxThe compatibility-mode ChangeCipherSpec exists precisely for that. A middlebox breaking 1.3 needs replacing
Monitor negotiated version and cipher, not just certificate expiryA config drift that silently re-enables TLS 1.0 or a CBC suite is invisible to certificate monitoring

E3 · Capstone exercise

Write a handshake inspector you will reuse. It exercises every part of this module: version and cipher negotiation, forward-secrecy detection, SNI behaviour, ALPN, chain length, and protocol-version probing.

Brief. Write a script tlsinfo that takes a hostname and reports:

  1. The negotiated protocol version, cipher suite and key exchange group
  2. Whether forward secrecy is in use, stated plainly
  3. Which protocol versions the server accepts — probe 1.0, 1.1, 1.2 and 1.3 individually
  4. Whether the certificate served with SNI differs from the one served without it
  5. The ALPN protocol agreed when offering h2,http/1.1
  6. How many certificates the server sends, and a warning if only one
Model answer — attempt it first, then click
bash
#!/usr/bin/env bash
# tlsinfo - inspect what a TLS endpoint actually negotiates
set -uo pipefail
host="${1:?usage: tlsinfo <hostname> [port]}"; port="${2:-443}"
S="openssl s_client -connect ${host}:${port} -servername ${host}"

printf '\n  %s:%s\n  %s\n\n' "$host" "$port" "$(printf '=%.0s' {1..58})"

# --- 1 & 2. what was negotiated ---
out=$($S </dev/null 2>/dev/null)
proto=$(printf '%s' "$out" | grep -m1 'Protocol *:' | sed 's/.*: *//')
ciph=$( printf '%s' "$out" | grep -m1 'Cipher *:'   | sed 's/.*: *//')
tmpk=$( printf '%s' "$out" | grep -m1 'Server Temp Key' | sed 's/.*: *//')
sigt=$( printf '%s' "$out" | grep -m1 'Peer signature type' | sed 's/.*: *//')

printf '  %-16s %s\n' "Protocol"  "${proto:-unknown}"
printf '  %-16s %s\n' "Cipher"    "${ciph:-unknown}"
printf '  %-16s %s\n' "Key group" "${tmpk:-<none>}"
printf '  %-16s %s\n' "Cert sig"  "${sigt:-unknown}"

if [ -n "$tmpk" ]; then
  printf '  %-16s ✅ yes (ephemeral %s)\n' "Forward secrecy" "${tmpk%%,*}"
else
  printf '  %-16s ❌ NO - RSA key transport. Recorded traffic is decryptable\n' "Forward secrecy"
fi

# --- 3. which versions are accepted ---
printf '\n  Protocol support:\n'
for v in tls1 tls1_1 tls1_2 tls1_3; do
  label=$(printf '%s' "$v" | sed 's/tls1_/TLS 1./; s/^tls1$/TLS 1.0/')
  if $S -$v </dev/null >/dev/null 2>&1; then
    case "$v" in
      tls1|tls1_1) printf '    %-10s ⚠️  ACCEPTED - deprecated by RFC 8996\n' "$label" ;;
      *)           printf '    %-10s ✅ accepted\n' "$label" ;;
    esac
  else
    printf '    %-10s ·  not offered\n' "$label"
  fi
done

# --- 4. SNI behaviour ---
with=$($S </dev/null 2>/dev/null | openssl x509 -noout -subject 2>/dev/null)
without=$(openssl s_client -connect "${host}:${port}" </dev/null 2>/dev/null \
          | openssl x509 -noout -subject 2>/dev/null)
printf '\n  SNI:\n'
printf '    with SNI     %s\n' "${with:-<none>}"
printf '    without SNI  %s\n' "${without:-<none>}"
[ "$with" != "$without" ] && printf '    ℹ️  differ - name-based virtual hosting is in use\n'

# --- 5. ALPN ---
alpn=$($S -alpn h2,http/1.1 </dev/null 2>/dev/null | grep -m1 -i 'ALPN protocol' | sed 's/.*: *//')
printf '\n  %-16s %s\n' "ALPN" "${alpn:-none negotiated}"

# --- 6. chain length ---
n=$($S -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE')
printf '  %-16s %s certificate(s)\n' "Chain sent" "$n"
[ "$n" -eq 1 ] && printf '    ⚠️  only ONE - intermediates missing. Browsers may hide this; curl/Java will fail\n'
printf '\n'

Try it:

bash
chmod +x tlsinfo
./tlsinfo www.google.com
./tlsinfo app.internal.test 4433     # your own lab server

The five design decisions worth understanding.

1. One connection for the summary, separate connections for probing. Requirement 3 needs a distinct handshake per version — you cannot learn which versions a server accepts from a single connection, because only one gets negotiated.

2. Forward secrecy is inferred from Server Temp Key, not from the cipher name. The cipher name works for TLS 1.2 (ECDHE- prefix) but not for 1.3, where key exchange was removed from the suite name. Server Temp Key is the field that works for both.

3. TLS 1.0/1.1 being accepted is flagged as a warning, not a fact. Reporting a value is less useful than reporting a judgement. A tool that says "⚠️ deprecated by RFC 8996" tells the reader what to do; one that prints "TLS 1.0: yes" does not.

4. The SNI comparison is a diagnostic, not a pass/fail. Differing certificates are perfectly normal on a shared host — it just tells you name-based virtual hosting is in play, which is the context you need when someone reports the wrong certificate.

5. The chain warning names who will fail. "Browsers may hide this; curl and Java will fail" is the sentence that prevents the argument, exactly as in Module 03's capstone.

What to add as you go through the track: revocation status and OCSP stapling (Module 09), HSTS and redirect behaviour (Module 08), and full cipher enumeration (Module 13). By Module 13 this and Module 03's certinfo merge into one genuinely useful auditing tool.


E4 · Official documentation — what to bookmark and how to read it

The single most useful page for this module: RFC 8446 §2 — Protocol Overview. It is about four pages, contains the full handshake diagram, and is one of the clearest sections in any IETF document.

Make it a reflex: when you see a handshake message you do not recognise, search RFC 8446 for its name. Every message has its own numbered subsection under §4, defining exactly what it contains and when it is sent.

Core reference pages

LinkWhat it is for
RFC 8446 — TLS 1.3The protocol. §2 is the overview, §4 is every message, §1.2 lists what changed from 1.2
RFC 8446 Appendix E.5 — 0-RTT replayThe definitive statement of the 0-RTT hazard, from the people who shipped it anyway
RFC 5246 — TLS 1.2The older protocol you still have to support. §7.3 is its handshake flow
RFC 8996 — Deprecating TLS 1.0 and 1.1The document to cite when someone asks you to re-enable them
RFC 6066 §3 — Server Name Indication · RFC 7301 — ALPNThe two extensions that make shared hosting and HTTP/2 work
openssl s_client manual-msg, -trace, -servername, -alpn, -groups, -reconnect. The tool for this whole module
openssl ciphers · openssl s_serverListing suites, and running a server you control for testing
TLSRef — Server Side TLS configuration · TLS ConfiguratorMaintained cipher and protocol configurations. Where Mozilla's guidance moved to
draft-ietf-tls-ecdhe-mlkem · NIST FIPS 203 — ML-KEMThe hybrid post-quantum key exchange, and the standard underneath it
SSL Labs Server Test · Cloudflare RadarAn external opinion on your configuration, and live adoption statistics

How to read openssl s_client output

The output is long and most of it is noise. These are the lines that carry information, and roughly what each answers:

plain text
Verify return code: 0 (ok)          <- did the CHAIN validate?              (Module 05)
Server Temp Key: X25519, 253 bits   <- FORWARD SECRECY?  present = yes      (Part B2)
Peer signature type: ECDSA          <- what signed the handshake            (Part A1)
    Protocol  : TLSv1.3             <- which VERSION was chosen             (Part C1)
    Cipher    : TLS_AES_256_GCM_...  <- which SUITE was chosen              (Part B3)
ALPN protocol: h2                   <- which APPLICATION protocol           (Part D2)
New, TLSv1.3, Cipher is ...         <- full handshake, or "Reused"          (Part D3)

Everything else — the certificate dump, the session block, the byte counts — is detail you can request when you need it. Learning to skip to these seven lines is most of the skill.

The offline alternative

bash
openssl s_client -help                 # every flag
openssl ciphers -v 'ALL:COMPLEMENTOFALL' | wc -l   # how many suites this build knows
openssl ciphers -s -tls1_3 -v          # the TLS 1.3 menu
man openssl-s_client                   # if the docs package is installed
🧪 Exercise E4.1 — Find the version-probing flags from the CLI
bash
openssl s_client -help 2>&1 | grep -E 'tls1|no_tls|ssl3|dtls' | head -12
Expected result — click to reveal
plain text
-tls1                      Just use TLSv1
-tls1_1                    Just use TLSv1.1
-tls1_2                    Just use TLSv1.2
-tls1_3                    Just use TLSv1.3
-no_tls1                   Disable TLSv1
-no_tls1_1                 Disable TLSv1.1
-no_tls1_2                 Disable TLSv1.2
-no_tls1_3                 Disable TLSv1.3
-dtls                      Use any version of DTLS

What to read out of this.

  • Two families: -tls1_2 forces exactly that version; -no_tls1_2 excludes it. For probing what a server supports you want the forcing form, one version per connection — which is what the capstone does.
  • The -no_ forms are for testing fallback. -no_tls1_3 tells you what a client that cannot do 1.3 would get, which is how you check whether your TLS 1.2 configuration is still sane.
  • -dtls is Datagram TLS, TLS over UDP — used by WebRTC, some VPNs and QUIC's ancestors. Same certificates, same chains, different transport. Out of scope here but worth recognising.

💡 A caveat you already met in Exercise C1.2: forcing -tls1 or -tls1_1 may fail in your own client before a packet is sent, because your distribution's security level forbids them. That is a client-side refusal, and the workaround is -cipher 'DEFAULT@SECLEVEL=0'. Without knowing that, you can wrongly conclude a server has already disabled them.


E5 · Self-assessment

Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.

1. What are the three jobs of a TLS handshake, and in what order?

Negotiate parameters, authenticate the server, agree a shared symmetric key — in that order. Agreeing a key before checking identity would establish a private channel with an unknown party, which is exactly the man-in-the-middle case.

2. Is the certificate's key used to encrypt the session key?

Not in TLS 1.3, and almost never in modern 1.2. It is used only to sign the handshake transcript, proving possession. The session key comes from a separate ephemeral Diffie-Hellman exchange the certificate takes no part in.

That separation is what gives forward secrecy.

3. Why is TLS 1.3 one round trip instead of two?

The client guesses the key exchange group and sends its key share in the ClientHello, before knowing what the server will choose. A correct guess — which is nearly always — completes the exchange in one trip. A wrong guess costs a HelloRetryRequest and one extra trip.

4. What is forward secrecy and how do you check for it?

Compromising the server's long-term private key does not decrypt past sessions, because the session key came from ephemeral keys that were destroyed.

Check with openssl s_client ... | grep 'Server Temp Key'. Present means yes. Absent on a TLS 1.2 connection means RSA key transport and no forward secrecy.

5. Read ECDHE-RSA-AES128-GCM-SHA256 aloud.

Ephemeral elliptic-curve Diffie-Hellman for key exchange (the trailing E is what gives forward secrecy); RSA certificate for authentication; AES-128 in GCM — an AEAD mode — for bulk encryption; SHA-256 for key derivation.

The TLS 1.3 equivalent is just TLS_AES_128_GCM_SHA256, because key exchange and authentication were removed from the suite name.

6. Why did TLS 1.3 remove RSA key transport?

No forward secrecy — the pre-master secret is encrypted to the certificate's public key, so obtaining that key later decrypts everything recorded. And a twenty-five-year run of Bleichenbacher padding-oracle attacks against PKCS#1 v1.5, most recently ROBOT in 2017.

The consequence you can see: a TLS 1.3 server certificate needs only digitalSignature, not keyEncipherment.

7. Which handshake messages did TLS 1.3 delete, and which is a fossil?

Deleted: ServerKeyExchange (the share moved into ServerHello) and ServerHelloDone (the structure makes it unnecessary).

Kept as a fossil: ChangeCipherSpec. TLS 1.3 does not need it — key changes are implied — but it is still sent so middleboxes written for 1.2 do not drop the connection. RFC 8446 calls this compatibility mode.

8. What is SNI, and is it encrypted?

Server Name Indication: the hostname the client is asking for, sent in the ClientHello so a server hosting many sites knows which certificate to present.

Not encrypted, even in TLS 1.3 — it is in the very first message, before any keys exist. So a passive observer still learns which site you visited. Encrypted Client Hello (ECH) is the fix.

Diagnostic value: if the certificate differs with and without -servername, you have a virtual-host configuration issue rather than a certificate issue.

9. What is 0-RTT and when should you enable it?

Sending application data in the first packet of a resumed TLS 1.3 connection — zero round trips.

It cannot be protected against replay: there is no shared state yet, so a recorded first packet can be sent again. Enable it only for idempotent requests — static GETs at a CDN — and have the application reject non-idempotent early data, e.g. 425 Too Early using nginx's $ssl_early_data.

10. Why does resumption behave differently on TLS 1.3?

In 1.2 the session is established during the handshake. In 1.3 the ticket arrives in a NewSessionTicket message after the handshake completes, so a client that connects and immediately closes never receives one.

Practically: resumption helps browsers making many connections, and does nothing for one-shot scripts. It also means openssl s_client -reconnect demonstrates 1.2 resumption cleanly but not 1.3.

11. What is X25519MLKEM768 and why now rather than later?

A hybrid key exchange combining classical X25519 with post-quantum ML-KEM-768 (NIST FIPS 203). The session key derives from both, so it holds if either does.

Now, because of harvest now, decrypt later — traffic recorded today can be decrypted whenever the capability arrives, so anything with long-term secrecy is already at risk. Default in Chrome and Edge since v131; roughly two thirds of human browser traffic by 2026; still an IETF draft.

It needs no certificate changes — it is key exchange, not signatures. Post-quantum signatures are a separate and far larger migration.

12. A middlebox breaks TLS 1.3. What do you do?

Not disable TLS 1.3. The protocol already ships a compatibility fossil — the redundant ChangeCipherSpec — specifically so 1.2-era middleboxes tolerate it. A device that still breaks is failing at its job and needs updating or replacing.

Disabling 1.3 to accommodate it costs forward-secrecy guarantees, the round-trip saving, and certificate privacy, permanently, to work around one broken appliance.


E6 · Command reference — everything from this module

The commands introduced in Module 06, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

The essential four lines

bash
openssl s_client -connect h:443 -servername h </dev/null 2>/dev/null \
  | grep -E 'Protocol *:|Cipher *:|Server Temp Key|Verify return code'    # ⭐ the whole summary

Version probing

bash
openssl s_client -connect h:443 -servername h -tls1_3 </dev/null 2>&1 | head -3   # ⭐
openssl s_client -connect h:443 -servername h -tls1_2 </dev/null 2>&1 | head -3   # ⭐
openssl s_client -connect h:443 -tls1_1 </dev/null 2>&1 | head -3                 # should fail
openssl s_client -connect h:443 -tls1_1 -cipher 'DEFAULT@SECLEVEL=0' </dev/null   # override your own client
openssl s_client -connect h:443 -servername h -no_tls1_3 </dev/null 2>&1 | grep Protocol  # what 1.2 clients get

Watch the handshake itself

bash
openssl s_client -connect h:443 -servername h -msg </dev/null 2>&1 \
  | grep -E '^(<<<|>>>)' | grep -v RecordHeader                          # ⭐ the message flow
openssl s_client -connect h:443 -servername h -trace </dev/null 2>&1 | head -60   # ⭐ decoded contents
openssl s_client -connect h:443 -servername h -trace </dev/null 2>&1 \
  | grep -iA3 'server_name'                                              # SNI on the wire

Cipher suites and groups

bash
openssl ciphers -s -tls1_3 -v                        # ⭐ the five-item TLS 1.3 menu
openssl ciphers -s -tls1_2 -v 'ECDHE+AESGCM'         # ⭐ the good TLS 1.2 suites
openssl ciphers -s -tls1_2 | tr ':' '\n' | wc -l     # how many 1.2 suites are enabled
openssl s_client -connect h:443 -cipher 'AES256-SHA' -tls1_2 </dev/null 2>&1 | head -2   # probe one suite
openssl s_client -connect h:443 -servername h -groups X25519MLKEM768 </dev/null 2>&1 \
  | grep 'Server Temp Key'                           # ⭐ post-quantum, needs OpenSSL 3.5+

Extensions

bash
openssl s_client -connect h:443 -servername h -alpn h2,http/1.1 </dev/null 2>/dev/null \
  | grep -i alpn                                     # ⭐ which application protocol
openssl s_client -connect h:443 -servername h </dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect h:443                  </dev/null 2>/dev/null | openssl x509 -noout -subject
                                                     # ⭐ compare the two: SNI vs default vhost

Resumption

bash
openssl s_client -connect h:443 -servername h -tls1_2 -reconnect </dev/null 2>/dev/null \
  | grep -iE '^(New|Reused)'                         # ⭐ 1 New + 5 Reused if resumption works
openssl s_client -connect h:443 -servername h -sess_out s.pem </dev/null   # save a session
openssl s_client -connect h:443 -servername h -sess_in  s.pem </dev/null   # try to resume it

Run your own server to test against

bash
openssl s_server -cert leaf.crt -cert_chain chain.pem -key leaf.key -accept 4433 -www   # ⭐
openssl s_server -cert leaf.crt -key leaf.key -accept 4433 -naccept 10                  # for -reconnect
openssl s_server -cert leaf.crt -key leaf.key -accept 4433 -tls1_2                      # force a version
pgrep -f 'accept 4433' | xargs -r kill                                                  # ⭐ stop it
The three-command handshake triage, when someone says "TLS is broken":
bash
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null \
  | grep -E 'Protocol *:|Cipher *:|Server Temp Key|Verify return code'
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERT'
for v in tls1 tls1_1 tls1_2 tls1_3; do printf '%-8s ' $v; \
  openssl s_client -connect host:443 -servername host -$v </dev/null >/dev/null 2>&1 \
  && echo accepted || echo no; done

What did it agree · did it send its chain · which versions will it speak. Between them these answer almost every "why won't this client connect" question.


Next — Module 07 · Certificate Validation: what a client actually checks.

You have now seen the handshake deliver a certificate and a chain. Module 07 is the client's side of that moment: the complete ordered list of checks a client performs before it accepts a connection, what each one rejects, and the exact error message each produces in curl, in a browser, in Java, in Go and in Python.

You will reproduce every common failure on purpose — expired, wrong host, untrusted root, incomplete chain, self-signed, revoked, wrong EKU — and learn to identify each from its error text alone, which is most of what TLS troubleshooting actually is.

Official reading ahead of it: RFC 5280 §6 — Certification Path Validation and RFC 9525 — Service Identity in TLS.

📚 Sources for the interview questions

Question selection was cross-referenced against publicly published 2026 TLS and SSL interview question sets, then rewritten and deepened:

Every handshake trace and negotiated-parameter output in this module was captured on OpenSSL 3.0.13 against a locally run s_server, including the side-by-side TLS 1.3 and TLS 1.2 message flows, the three-suite TLS 1.3 menu, the TLS 1.2 resumption run, and the no protocols available client-side refusal of TLS 1.1. The observation that openssl s_client -reconnect does not demonstrate TLS 1.3 resumption — because the ticket arrives after the handshake — came from testing rather than recall.

Standards and current adoption were verified against primary sources: RFC 8446, RFC 5246, RFC 6066, RFC 7301, draft-ietf-tls-ecdhe-mlkem, NIST FIPS 203, OpenSSL's post-quantum documentation, and Cloudflare adoption figures reported through 2026.

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.