Skip to content

TLS Handshake Explained

CoreConceptJuly 22, 20268 min read

A payment retry button, wired to a 0-RTT-resumed TLS connection for speed, gets pressed twice by an impatient user — and somewhere on the network, a captured copy of that first 0-RTT flight could be replayed by an attacker to trigger the exact same request a third time, with the server having no TLS-level way to tell the replay apart from a legitimate resend. This isn't a bug in TLS 1.3; it's a documented, deliberate trade-off of a real speed optimization, and most surface-level explanations of "the TLS handshake" never mention it.

This guide traces TLS 1.3's actual 1-RTT handshake step by step, explains why forward secrecy required removing static RSA key exchange entirely, covers OCSP stapling as the efficient fix for a slow and privacy-leaking certificate-revocation check, and then covers 0-RTT resumption's genuine replay risk and the idempotency requirement it places on any server that enables it. For where TLS sits in a full page load, see What Happens After You Type a URL.

TLS 1.3: one round trip from ClientHello to encrypted application data
TLS 1.3: one round trip from ClientHello to encrypted application data

The 1-RTT Handshake: ClientHello to Finished

TLS 1.3 completes a fresh handshake in one round trip, down from TLS 1.2's two. The client sends a ClientHello listing supported cipher suites and, critically, a key_share extension containing ephemeral Diffie-Hellman public values for the groups it thinks the server is likely to pick — sent opportunistically, before the server has said anything, which is what collapses the handshake to one round trip instead of negotiating the group first and exchanging keys second.

The server responds with ServerHello (its chosen cipher suite and its own key share, letting both sides now independently derive the same shared secret), and — already encrypted using that just-derived secret — sends EncryptedExtensions, its Certificate, a CertificateVerify (proving it holds the certificate's private key), and a Finished message. The client verifies the certificate chain, sends its own Finished, and application data can begin flowing immediately after. Compare this to TLS 1.2, which needed a full round trip just to agree on a cipher suite before any key material was exchanged at all.

1-RTT: opportunistic key_share collapses negotiation and exchange into one round trip
1-RTT: opportunistic key_share collapses negotiation and exchange into one round trip

Quick reference

  • key_share is sent opportunistically in ClientHello, guessing the likely group — this is what makes 1-RTT possible.
  • Server's Certificate, CertificateVerify, and Finished are already encrypted with the freshly-derived shared secret.
  • CertificateVerify proves the server actually holds the certificate's private key, not just that it presented a valid-looking certificate.
  • TLS 1.2 needed a separate round trip for cipher negotiation before any key material exchange — TLS 1.3's 1-RTT is a real latency win, not just a version bump.
  • If the server doesn't support the client's guessed group, one extra round trip (HelloRetryRequest) is needed — the common case is still 1-RTT.

Remember this

TLS 1.3 reaches encrypted application data in one round trip by having the client guess a likely key-exchange group upfront — a real latency improvement over TLS 1.2's separate negotiate-then-exchange round trips.

Forward Secrecy: Why TLS 1.3 Dropped Static RSA Key Exchange

Older TLS versions allowed a static RSA key exchange: the client encrypted a secret directly with the server's long-term RSA public key. This meant that if the server's private key was ever compromised — stolen, subpoenaed, or leaked — years of previously recorded encrypted traffic could be decrypted retroactively, because that same long-term key had been used to protect every one of those past sessions.

TLS 1.3 removed static RSA key exchange entirely, requiring (EC)DHE — ephemeral Diffie-Hellman — for every handshake. Each connection generates a fresh, temporary key pair used only for that one session and discarded immediately after; the server's long-term certificate key is used only to sign the exchange (proving identity), never to directly encrypt the shared secret. This is forward secrecy: compromising the server's long-term private key today reveals nothing about any past session, because past sessions never depended on that key for their actual encryption — each one used its own now-discarded ephemeral key.

Ephemeral key per session — a future key leak reveals nothing about past traffic
Ephemeral key per session — a future key leak reveals nothing about past traffic

Quick reference

  • Static RSA key exchange: one long-term key protects every past session — a future compromise decrypts historical traffic.
  • (EC)DHE: a fresh ephemeral key pair per session, discarded after use — the long-term certificate key only signs, never encrypts.
  • Forward secrecy specifically means a future key compromise cannot decrypt past sessions — it says nothing about protecting future sessions after a compromise.
  • TLS 1.3 makes forward secrecy mandatory by removing static RSA key exchange as an option entirely, not just recommending ephemeral exchange.
  • This is precisely why intercepted TLS 1.3 traffic recorded today stays unreadable even if a server's certificate key leaks years later.

Remember this

Forward secrecy exists because TLS 1.3 uses a fresh, discarded key pair per session for actual encryption — the server's long-term key only signs the exchange, so compromising it later reveals nothing about traffic already recorded.

Certificate Verification and OCSP Stapling

Verifying a certificate means walking its chain up to a trusted root CA, checking the certificate's validity dates, and confirming the hostname being connected to matches one of the certificate's Subject Alternative Names — a mismatch here is what produces the browser's "certificate not valid for this domain" warning. The remaining question is revocation: has the certificate been invalidated before its expiry date, for example because its private key leaked?

The traditional approach — the client separately querying the CA's OCSP (Online Certificate Status Protocol) responder for every connection — is slow (an extra round trip to a third party) and leaks privacy (the CA learns every site you visit, from every one of your revocation checks). OCSP stapling fixes both: the server itself periodically fetches a signed, time-stamped "not revoked" response from the CA and includes ("staples") it directly in the TLS handshake, so the client verifies revocation status from a signature it already received, with no separate call to any third party at all.

OCSP stapling: the server presents a signed proof, no client-to-CA call needed
OCSP stapling: the server presents a signed proof, no client-to-CA call needed

Quick reference

  • Certificate chain verification: each certificate signed by the next, up to a trusted root CA already present in the client's trust store.
  • Hostname/SAN check: the certificate must explicitly cover the domain being connected to — a valid certificate for the wrong domain still fails.
  • OCSP stapling: the server presents a CA-signed 'not revoked' proof directly in the handshake — no client-to-CA round trip, no per-visit privacy leak to the CA.
  • A stapled OCSP response is itself time-stamped and expires — servers must refresh it periodically, not staple one response indefinitely.
  • Certificate Transparency logs are a separate, complementary mechanism (public append-only logs of issued certificates) for detecting mis-issued certificates after the fact — not part of the handshake itself.

Remember this

OCSP stapling moves the revocation check from a slow, privacy-leaking client-to-CA round trip into a signed proof the server already includes in the handshake — the client verifies a signature it already has instead of calling a third party.

Recover One 0-RTT Replay Incident

Trigger. A client that previously connected to a server resumes the session using 0-RTT: it sends application data — in this case, a POST /payments/retry request — in its very first flight, using a pre-shared key derived from the earlier session, without waiting for any round trip at all. Symptom. An attacker who captured that first flight off the network replays the identical encrypted 0-RTT data later; the server, having no way within TLS itself to distinguish a legitimate resend from a malicious replay, processes the payment retry a second time.

Root mechanism. 0-RTT data arrives before the handshake's anti-replay protections (which depend on a completed round trip) are in effect — this is a documented property of the 0-RTT design, not an implementation bug, and RFC 8446 explicitly warns that 0-RTT data is replayable. Recovery: the payment retry endpoint's own idempotency key (the same pattern covered in Exactly-Once Delivery) catches the duplicate at the application layer, where TLS cannot. Verification: confirm the payment provider shows exactly one successful charge despite two identical requests reaching the server. Prevention: only ever enable 0-RTT for genuinely idempotent requests (a cacheable GET, a read with no side effect) — never for a state-changing action — or disable 0-RTT entirely for endpoints that can't guarantee idempotency at the application layer.

0-RTT payment retry replayed — idempotency key is the only real defense
0-RTT payment retry replayed — idempotency key is the only real defense

Quick reference

  • Trigger: 0-RTT sends application data before a full round trip's anti-replay protection is established.
  • Symptom: a captured 0-RTT flight can be replayed, and the server has no TLS-level signal distinguishing replay from legitimate resend.
  • Root mechanism: this is documented, expected 0-RTT behavior per RFC 8446 — not a flaw to patch, a trade-off to design around.
  • Recovery: application-layer idempotency (an idempotency key checked before the side effect) catches what TLS cannot.
  • Prevention: enable 0-RTT only for idempotent requests, and treat idempotency keys as required — not optional — for any state-changing endpoint regardless of 0-RTT status.
0-RTT enabled for a non-idempotent endpoint
1# Server enables 0-RTT broadly, including for state-changing POST endpoints2ssl_early_data on;3location /payments/retry {4  # No idempotency key required — a replayed 0-RTT flight5  # reaches this handler exactly like a legitimate request.6}
0-RTT restricted, idempotency required where it's on
1# Restrict 0-RTT to genuinely idempotent routes only2location /catalog {3  ssl_early_data on; # safe: read-only, replay has no side effect4}5location /payments/retry {6  ssl_early_data off; # force a full round trip for this state-changing action7  # AND require an idempotency key regardless, as defense in depth8}

Remember this

0-RTT resumption's replay risk is a documented trade-off of the speed optimization, not a bug — the only real defense is application-layer idempotency, and 0-RTT should be enabled only for requests where a replay would be harmless anyway.

Key takeaway

TLS 1.3 reaches encrypted application data in one round trip by having the client guess a key-exchange group upfront, and makes forward secrecy mandatory by requiring a fresh ephemeral key pair per session instead of ever encrypting directly with a long-term key. OCSP stapling turns a slow, privacy-leaking revocation check into a signed proof already inside the handshake. 0-RTT resumption trades a full round trip for a real, documented replay risk that only application-layer idempotency can actually close.

Practice (25 min): capture a TLS 1.3 handshake with a tool like openssl s_client or your browser's network inspector and identify the ClientHello's key_share, the ServerHello, and the Finished messages in the trace. Confirm OCSP stapling is active for a real HTTPS site using your browser's certificate viewer. Then design an idempotency-key check for a payment-retry endpoint, and explain specifically why that check — not anything TLS provides — is what makes it safe to enable 0-RTT resumption on that endpoint. Pass when you can name, for your own API's riskiest state-changing endpoint, whether 0-RTT is currently enabled and whether an idempotency key actually protects it.

Share:

Related Articles

A permission check in front of a wire-transfer approval endpoint times out. What should happen next? One engineer's inst

Read

You press Enter on https://shop.example/products/42. A moment later, a product page appears. That small action crosses n

Read

Many teams still introduce NGINX as “just a web server.” In production it usually sits in front of your app: clients hit

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.