What Actually Happens During a TLS 1.3 Handshake: 1-RTT, 0-RTT, and Why Your HTTPS Is Faster Than You Think
What Actually Happens During a TLS 1.3 Handshake: 1-RTT, 0-RTT, and Why Your HTTPS Is Faster Than You Think You hit https://example.com. The browser shows a lock icon. Done. But between that click and the lock, a prec
What Actually Happens During a TLS 1.3 Handshake: 1-RTT, 0-RTT, and Why Your HTTPS Is Faster Than You Think
You hit https://example.com. The browser shows a lock icon. Done.
But between that click and the lock, a precise cryptographic negotiation just happened — one that determines whether your connection is actually private, whether it can be replayed by an attacker, and whether the server you're talking to is who they claim to be.
Most developers know TLS 1.3 is "faster" and "more secure." Few can explain what actually happens during the handshake, why it dropped from two round-trips to one, what 0-RTT actually buys you (and what it doesn't), or why session resumption works completely differently than it did in TLS 1.2.
This article traces the TLS 1.3 handshake message by message, shows where the performance gains come from, and explains the security tradeoffs that most documentation glosses over.
The Old Way: TLS 1.2 Took Two Round-Trips
In TLS 1.2, a full handshake with ECDHE looked like this:
Client Server
|--- ClientHello (ciphers, extensions) ---------->|
|<-- ServerHello (cipher), Certificate, KeyShare --|
|<-- CertificateVerify, ServerFinished -----------|
|--- ClientKeyShare, ClientFinished ------------->|
|<-- Application Data ----------------------------|
Two full round-trips before the client could send a single byte of application data. The client had to wait for the server's KeyShare before it could compute the shared secret and derive encryption keys.
The server also sent its certificate and CertificateVerify in plaintext — anyone on the wire could see which certificate the server presented.
TLS 1.3: The 1-RTT Handshake
TLS 1.3 rearranges the entire flow. The client speculatively sends its key share in the very first message:
Client Server
|--- ClientHello + KeyShare(s) ------------------>|
|<-- ServerHello + KeyShare, {EncryptedExtensions}|
|<-- {Certificate}, {CertificateVerify}, {Finished}|
|--- {ClientFinished} ---------------------------->|
|<-- [Application Data] <----------------------->|
The braces {} mean encrypted. The brackets [] mean application traffic keys.
What Changed
| Aspect | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Round-trips (full handshake) | 2 | 1 |
| Certificate visibility | Plaintext | Encrypted |
| Key exchange | RSA, DH, ECDHE | (EC)DHE only |
| Cipher suites | Dozens | 5 mandatory (AEAD only) |
| Resumption | Session ID / Ticket | PSK only |
| 0-RTT | Not supported | Supported |
The ClientHello Now Carries Key Shares
The client guesses which key agreement groups the server supports (X25519, P-256, P-384, etc.) and sends a key_share extension with its ephemeral public key for each.
ClientHello:
- legacy_version: 0x0303 (TLS 1.2, for middlebox compat)
- random: 32 bytes
- cipher_suites: [TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, ...]
- extensions:
- supported_versions: [TLS 1.3, TLS 1.2]
- key_share: [
{ group: x25519, key_exchange: <32-byte public key> },
{ group: secp256r1, key_exchange: <65-byte public key> }
]
- signature_algorithms: [rsa_pss_rsae_sha256, ecdsa_secp256r1_sha256, ...]
- supported_groups: [x25519, secp256r1, secp384r1, ...]
If the server supports one of the groups, it picks it, generates its own ephemeral key, and immediately computes the shared secret. No waiting for a second flight.
If the client guessed wrong (rare), the server sends a HelloRetryRequest with the correct group — falling back to 2-RTT. But modern clients and servers almost always agree on X25519 or P-256.
The Server Responds With Everything Encrypted
Once the server has the client's key share, it derives the handshake traffic secrets and can encrypt everything from EncryptedExtensions onward:
ServerHello:
- legacy_version: 0x0303
- random: 32 bytes
- cipher_suite: TLS_AES_256_GCM_SHA384
- extensions:
- supported_versions: TLS 1.3
- key_share: { group: x25519, key_exchange: <server public key> }
Then, encrypted under handshake keys:
{EncryptedExtensions} <- server extensions (ALPN, etc.)
{Certificate} <- server cert chain (now encrypted!)
{CertificateVerify} <- signature over *entire handshake transcript*
{Finished} <- HMAC over transcript, key confirmation
The client receives these, verifies the certificate chain, checks the CertificateVerify signature against the transcript hash, validates Finished, derives the application traffic secrets, and sends its own {Finished}. Done. Application data flows.
The Key Schedule: Where the Secrets Come From
TLS 1.3 uses a hierarchical key derivation based on HKDF (HMAC-based Key Derivation Function). No more ad-hoc PRFs.
PSK (or 0) + (EC)DHE shared secret
│
▼
┌────────────────────────┐
│ Early Secret │ ◄─ Derived from PSK + "derived" label
│ (for 0-RTT only) │
└───────────────────│
│ Derive-Secret(., "derived", "")
▼
┌─────────────────────────┐
│ Handshake Secret │ ◄─ Derived from Early Secret + (EC)DHE
└─────────────────────│
│ Derive-Secret(., "handshake", "")
▼
┌──────────────────────────┐
▼ ▼
Client Handshake Traffic Secret Server Handshake Traffic Secret
│ │
▼ ▼
Client Handshake Keys Server Handshake Keys
(encrypts ClientFinished) (encrypts ServerFinished)
│
▼ Derive-Secret(., "derived", "")
┌────────────────────────┐
│ Master Secret │
└────────────────────│
│ Derive-Secret(., "master", "")
▼
┌────────────│───────────┐
▼ ▼
Client Application Traffic Secret Server Application Traffic Secret
│ │
▼ ▼
Client Application Keys Server Application Keys
(encrypts HTTP requests) (encrypts HTTP responses)
Each secret is derived from the previous using HKDF-Extract + HKDF-Expand-Label. The transcript hash (a running SHA-256/384 hash of all handshake messages) is mixed into every derivation — this binds keys to the exact handshake that produced them.
Resumption: PSK Replaces Session IDs and Tickets
TLS 1.2 had two resumption mechanisms:
- Session ID: Server stores session state in memory, client sends ID reference
- Session Ticket: Server encrypts session state, client sends opaque blob
Both are gone in TLS 1.3. Replaced by a single Pre-Shared Key (PSK) mechanism.
How It Works
After a successful 1-RTT handshake, the server sends a NewSessionTicket message (encrypted under application keys):
NewSessionTicket:
- ticket_lifetime: seconds (e.g., 86400 = 24h)
- ticket_age_add: obfuscation value
- ticket_nonce: random
- ticket: <opaque blob — either server-side reference or encrypted PSK>
The client stores this ticket. On next connection:
Client Server
|--- ClientHello + PSK + KeyShare* -------------->|
|<-- ServerHello + PSK + KeyShare* ---------------|
|<-- {EncryptedExtensions}, {Finished} -----------|
|--- {Finished} ---------------------------------->|
|<-- [Application Data] <------------------------>|
-
KeyShareis optional but recommended — it lets the server decline resumption and fall back to full 1-RTT with fresh forward secrecy.
Two PSK Key Exchange Modes
| Mode | Forward Secrecy? | Use Case |
|---|---|---|
psk_ke |
No — keys derived only from PSK + nonces | Low-latency, no FS needed |
psk_dhe_ke |
Yes — mixes fresh (EC)DHE into master secret | Default for browsers |
The client advertises supported modes in psk_key_exchange_modes extension. Modern clients only offer psk_dhe_ke — if the server doesn't support it, they fall back to full handshake.
The Ticket Can Be Stateless
The ticket blob can be:
- Stateful: Reference to server-side session cache entry
- Stateless: Encrypted copy of the PSK + metadata (like TLS 1.2 session tickets)
Stateless tickets let the server scale horizontally — no shared session cache needed. The server just encrypts the PSK with a ticket key only it knows.
0-RTT: Sending Data Before the Handshake Finishes
This is TLS 1.3's headline feature. If the client has a valid PSK from a previous connection, it can send application data immediately with the ClientHello:
Client Server
|--- ClientHello + PSK + EarlyData -------------->|
|<-- ServerHello + PSK, {EncryptedExtensions} ----|
|<-- {Finished} ----------------------------------|
|--- {Finished} ---------------------------------->|
|<-- [Application Data] <------------------------>|
The early data is encrypted under the client_early_traffic_secret, derived solely from the PSK and ClientHello — no server input.
The Catch: Replay Attacks
0-RTT data has no forward secrecy and no replay protection built into the protocol.
If an attacker captures a ClientHello + EarlyData packet and replays it:
- The server decrypts it (has the PSK from the ticket)
- Processes the HTTP request inside
- No way to know it's a duplicate — the ticket is stateless
Real-world impact: A replayed POST /transfer?amount=500 executes multiple times.
Mitigations (Server-Side Only)
The protocol cannot fix this — it's up to the application/server:
- Only allow idempotent requests in 0-RTT (GET, HEAD, OPTIONS)
- Reject 0-RTT for state-changing endpoints — force full 1-RTT
- Single-use tickets: Server tracks which tickets were used for 0-RTT and rejects duplicates (requires state)
-
Client hello recording: Server remembers recent
ClientHellohashes and rejects duplicates (short window) - Freshness checks: Embed timestamps in tickets, reject old ones
Cloudflare rotates ticket keys hourly — limiting the window where a stolen ticket key can decrypt 0-RTT data.
Why TLS 1.3 Dropped RSA Key Transport
TLS 1.2 supported RSA key exchange: client encrypts a premaster secret with the server's RSA public key. Simple, but:
- No forward secrecy — if the server's RSA key is compromised later, all past sessions can be decrypted
- No key separation — same key used for encryption and authentication
- Vulnerable to padding oracle attacks (Bleichenbacher)
TLS 1.3 mandates (EC)DHE for every handshake. Even with PSK resumption, the default psk_dhe_ke mode adds a fresh ephemeral key exchange. Forward secrecy is now the baseline, not an option.
Cipher Suites: Only AEAD, Only 5 Mandatory
TLS 1.2 had ~300 cipher suites. TLS 1.3 defines 5:
| Cipher Suite | Encryption | Hash |
|---|---|---|
TLS_AES_256_GCM_SHA384 |
AES-256-GCM | SHA-384 |
TLS_CHACHA20_POLY1305_SHA256 |
ChaCha20-Poly1305 | SHA-256 |
TLS_AES_128_GCM_SHA256 |
AES-128-GCM | SHA-256 |
TLS_AES_128_CCM_SHA256 |
AES-128-CCM | SHA-256 |
TLS_AES_128_CCM_8_SHA256 |
AES-128-CCM (8-byte tag) | SHA-256 |
All are AEAD (Authenticated Encryption with Associated Data) — encryption + integrity in one primitive. No more MAC-then-encrypt, no CBC padding oracles, no RC4, no SHA-1, no 3DES.
What Developers Actually Need to Know
1. Your HTTPS Is 1-RTT by Default
No config needed. Modern browsers + modern servers = 1 round-trip handshake. On a 100ms RTT link, that's 100ms saved per connection vs TLS 1.2.
2. 0-RTT Is Opt-In and Dangerous by Default
Browsers do not send 0-RTT automatically for navigations. It's used for:
- Fetch API with
credentials: 'include'on same-origin - Explicitly configured clients (curl
--tls13-early-data, some CDNs)
If you control the server, disable 0-RTT for non-idempotent endpoints. Most servers (nginx, Apache, Caddy) let you control this per-location.
3. Session Resumption Is Stateless by Default
The ticket the client receives is an encrypted PSK. Your server doesn't need a shared Redis cache for resumption to work across multiple instances — just share the ticket encryption key.
But: rotate that ticket key regularly. If it's compromised, all 0-RTT data encrypted with tickets from that period is decryptable.
4. Certificate Verification Happens After Key Agreement
In TLS 1.2, the client verified the cert before deriving keys. In 1.3, the client derives handshake keys from the (EC)DHE share first, then decrypts and verifies the certificate. This is why the certificate is encrypted — the keys already exist.
5. Middleboxes Broke TLS 1.3 Deployment
Some corporate firewalls and antivirus products assumed TLS 1.2 structure and dropped 1.3 connections. The legacy_version = 0x0303 and supported_versions extension are compatibility hacks. If you see HelloRetryRequest or fallback to 1.2, a middlebox is likely interfering.
Summary: The Mental Model
┌────────────────────────────────────────────────────┐
│ TLS 1.3 HANDSHAKE │
├────────────────────────────────────────────────┘
│ 1. ClientHello + KeyShare(s) │
│ → Client guesses server's group, sends ephemeral pubkey │
│ │
│ 2. ServerHello + KeyShare │
│ → Server picks group, sends its ephemeral pubkey │
│ → Both sides now have shared secret → handshake keys │
│ │
│ 3. {EncryptedExtensions, Certificate, CertVerify, Finished} │
│ → Everything encrypted under handshake keys │
│ → CertVerify signs *entire transcript* │
│ │
│ 4. {Finished} │
│ → Client confirms handshake integrity │
│ → Both derive application traffic secrets │
│ │
│ 5. [Application Data] │
│ → Encrypted under application keys │
└──────────────────────────────────────────────────┘
RESUMPTION (PSK):
ClientHello + PSK (+ KeyShare for FS) → 1-RTT (or 0-RTT)
0-RTT:
ClientHello + PSK + EarlyData → Data sent before server replies
→ No forward secrecy, replayable — use only for idempotent GETs
Further Reading
- RFC 8446 — The specification (readable, not just for implementers)
- RFC 8448 — Example handshake traces with real bytes
- Cloudflare TLS 1.3 Overview — Visual diagrams, practical takeaways
- Tim Taubert: Future of Session Resumption — PSK design rationale
Every HTTPS request you make runs this protocol. Understanding it means you can debug connection failures, configure servers correctly, and make informed decisions about 0-RTT tradeoffs.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.