Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

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] <------------------------>|
  • KeyShare is 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:

  1. Only allow idempotent requests in 0-RTT (GET, HEAD, OPTIONS)
  2. Reject 0-RTT for state-changing endpoints — force full 1-RTT
  3. Single-use tickets: Server tracks which tickets were used for 0-RTT and rejects duplicates (requires state)
  4. Client hello recording: Server remembers recent ClientHello hashes and rejects duplicates (short window)
  5. 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

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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.