Networking

The TLS Handshake

How a browser and server agree on keys and verify identity before any encrypted byte flows

The TLS handshake is the negotiation a browser and server run before any encrypted byte flows: they pick a cipher, exchange an ephemeral Diffie-Hellman key share, verify identity with a signed certificate, and derive a shared key schedule — in one round trip for TLS 1.3.

  • Full handshake (TLS 1.3)1-RTT
  • Resumption0-RTT
  • Key exchangeECDHE (forward secret)
  • Key derivationHKDF-Extract / Expand
  • AuthenticationCertificateVerify signature

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The 200-millisecond negotiation before "hello"

Every time you load an https:// URL, your browser and the server run a fast, structured negotiation before a single byte of the actual web page is sent. They have never met. They must agree on which cryptography to use, prove to each other (or at least the server proves to the client) who they are, and conjure a shared secret key out of a public conversation that an eavesdropper is watching the whole time. That negotiation is the TLS handshake, and on a typical connection it adds roughly one network round trip — often 20–100 ms — to the front of your request.

The deep puzzle the handshake solves is this: two parties shout across a public channel, an attacker records every word, and yet by the end they share a secret that the attacker does not. That feels impossible, and for most of computing history it was. The breakthrough was the Diffie-Hellman key exchange (1976), which lets two parties derive a common secret from public messages. TLS wraps that core trick in a protocol that also handles identity — making sure the public key you just exchanged with really belongs to bank.com and not an attacker sitting in the middle.

So the handshake has three jobs, run almost simultaneously: negotiate (agree on a TLS version and cipher suite), exchange keys (run ephemeral Diffie-Hellman to get a shared secret), and authenticate (verify the server's identity via a certificate and a live signature). Get all three right and everything after the handshake is symmetric encryption — fast, and provably tied to this exact session.

The TLS 1.3 message flow, byte by byte

TLS 1.3 (RFC 8446, 2018) collapsed the older two-round-trip dance into a single round trip by having the client guess the key-exchange parameters up front. The full handshake is just two flights of messages:

  Client                                              Server
  ------                                              ------
  ClientHello
    + supported_versions  (TLS 1.3)
    + cipher_suites       (AES-128-GCM, ChaCha20, ...)
    + key_share           (client's ephemeral X25519 public key)  --->

                                                  ServerHello
                                            + supported_versions
                                            + key_share  (server's X25519 pub key)
                                          {EncryptedExtensions}
                                          {Certificate}        (the chain)
                                          {CertificateVerify}  (signs transcript)
                                          {Finished}           (MAC of transcript)
                                   <---  [Application Data possible]
  {Finished}
  [Application Data]                                  --->

Braces { } mark messages encrypted under the handshake traffic keys; brackets [ ] mark messages encrypted under the application traffic keys. Only the two Hello messages travel in plaintext, because before the key shares are exchanged no keys exist yet.

The crucial sequencing: the instant the client receives ServerHello, both sides possess each other's ephemeral public key, so both can compute the same Diffie-Hellman shared secret independently. From that moment everything is encrypted. The server's Certificate (the chain binding its public key to its domain), its CertificateVerify (a signature over the entire handshake transcript so far, proving it holds the certificate's private key), and its Finished (a MAC over the transcript that detects tampering) all ride inside that encrypted envelope. The client validates the chain, checks the signature, verifies the Finished MAC, sends its own Finished, and the connection is open.

The key schedule: from one secret to a dozen keys

The raw output of X25519 Diffie-Hellman is 32 bytes of shared secret — but it is not uniformly random (it lives on an elliptic curve, not in the full byte space), and TLS needs many distinct keys, not one. The key schedule fixes both problems using HKDF, the HMAC-based Key Derivation Function (RFC 5869), in two phases:

  • ExtractHKDF-Extract(salt, IKM) runs HMAC to compress non-uniform input keying material into a fixed-length, uniformly pseudorandom secret. TLS 1.3 runs Extract three times in a chain to produce the Early, Handshake, and Master secrets: the first folds in the PSK (or zeros, for resumption), the second folds in the DH shared secret, and the third folds in zeros.
  • ExpandHKDF-Expand-Label(secret, label, transcript_hash, length) stretches a secret into as many context-bound keys as needed. Each key carries a label ("c hs traffic", "s ap traffic", "exp master", …) and folds in the running transcript hash, so every derived key is cryptographically tied to the exact bytes exchanged so far.

Folding the transcript hash into derivation is what makes the keys non-replayable: change one byte of any handshake message and every downstream key changes, the Finished MACs disagree, and the connection aborts. The schedule produces, among others: the client/server handshake traffic secrets (encrypt the certificate and Finished), the client/server application traffic secrets (encrypt your actual data), the exporter master secret (for channel binding), and the resumption master secret (for fast reconnects). The cost of all this is a handful of HMAC calls — microseconds — dwarfed by the network round trip.

When the handshake details actually matter

  • Latency budgets. If you serve users far from your origin, the handshake RTT is pure overhead on the first request. Knowing 1-RTT vs 2-RTT vs 0-RTT directly informs whether you deploy TLS 1.3, session resumption, or a CDN edge close to users.
  • Forward secrecy requirements. If you handle data that must stay confidential even if your private key later leaks (think "harvest now, decrypt later" adversaries), you need ephemeral key exchange — which TLS 1.3 enforces and old RSA-key-transport suites do not.
  • 0-RTT trade-offs. Early data shaves a round trip off reconnections but is replayable by an attacker. You must only send idempotent requests as 0-RTT — a GET is fine, a "transfer $100" POST is not.
  • Debugging the failure. Most "this site can't provide a secure connection" errors are handshake failures: a clock skew that makes the certificate look expired, a missing intermediate in the chain, an SNI mismatch, or a version/cipher mismatch. The flow above tells you which message blew up.

TLS 1.3 vs 1.2 vs QUIC vs the old RSA model

TLS 1.3TLS 1.2 (ECDHE)TLS 1.2 (RSA transport)QUIC / TLS 1.3 over UDP
Full handshake RTTs1221 (folded into transport)
Resumed handshake0-RTT (with early data)1-RTT (session ticket)1-RTT0-RTT
Forward secrecyAlwaysYesNoAlways
Certificate sent encryptedYesNo (plaintext)No (plaintext)Yes
Cipher negotiationFixed AEAD-only listLarge, includes weak suitesLarge, includes weak suitesSame as TLS 1.3
Key exchangeECDHE / FFDHE onlyECDHE or DHERSA encrypts secretECDHE only
StatusCurrent defaultLegacy, widely supportedDeprecatedHTTP/3 default

The headline shift from 1.2 to 1.3 is that key exchange and authentication now happen in parallel rather than in sequence. TLS 1.2 first negotiated the cipher (one RTT), then exchanged keys (another RTT). TLS 1.3 has the client send its key share speculatively in the very first message, so the server can reply with everything at once. The removal of RSA key transport — which had no forward secrecy and enabled the long line of Bleichenbacher RSA padding-oracle attacks, revived as recently as ROBOT in 2017 — is the other big security win.

What the numbers actually say

  • Handshake adds ~1 RTT of latency. On a 30 ms-RTT path, a fresh TLS 1.3 handshake costs about 30 ms before the HTTP request even leaves; TLS 1.2 cost ~60 ms. Resumption with 0-RTT drops the added latency to roughly zero.
  • Symmetric crypto is essentially free; the round trip is not. AES-GCM on modern x86 with AES-NI runs at multiple gigabytes per second — single-digit nanoseconds per block. The entire key schedule is a few dozen HMAC-SHA-256 calls, under ~10 microseconds. The dominant cost of the handshake is network latency, not computation.
  • The certificate signature is the heaviest CPU step. The server signs the transcript in CertificateVerify; an RSA-2048 signature takes on the order of a millisecond of CPU, while ECDSA-P256 is several times faster. At scale this is why busy front ends prefer ECDSA certificates.
  • X25519 is the de-facto default curve. A single X25519 scalar multiplication is a few tens of microseconds and is the curve almost all modern clients put first in their key_share, so the server's guess almost never needs a retry (HelloRetryRequest).

A JavaScript walkthrough of the shared-secret derivation

The full handshake is too much to fit in one snippet, but its conceptual heart — both sides independently arriving at the same key from public messages — fits neatly using the browser's WebCrypto API. This runs an ECDH exchange on P-256 and then derives a TLS-style traffic key with HKDF, exactly as the key schedule does:

// Both peers generate an EPHEMERAL keypair (thrown away after — forward secrecy).
async function genKeyShare() {
  return crypto.subtle.generateKey(
    { name: 'ECDH', namedCurve: 'P-256' },
    true,                       // extractable so we can export the public part
    ['deriveBits']
  );
}

// Each side computes the SAME shared secret from its own private key
// and the peer's public key. This is the Diffie-Hellman step.
async function sharedSecret(myPrivate, peerPublic) {
  return crypto.subtle.deriveBits(
    { name: 'ECDH', public: peerPublic },
    myPrivate,
    256                          // 32 bytes of raw, non-uniform DH output
  );
}

// HKDF turns the raw secret into a uniform, context-bound traffic key.
// The 'info' field is TLS's per-key label + transcript binding.
async function deriveTrafficKey(rawSecret, label, transcriptHash) {
  const ikm = await crypto.subtle.importKey('raw', rawSecret, 'HKDF', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),          // TLS chains real salts; zero here for clarity
      info: new TextEncoder().encode(label + '|' + transcriptHash),
    },
    ikm,
    { name: 'AES-GCM', length: 128 },     // the AEAD that protects application data
    false,
    ['encrypt', 'decrypt']
  );
}

// --- Simulate the two sides of the handshake ---
const client = await genKeyShare();
const server = await genKeyShare();

// In a real handshake these public keys travel in ClientHello / ServerHello.
const clientSecret = await sharedSecret(client.privateKey, server.publicKey);
const serverSecret = await sharedSecret(server.privateKey, client.publicKey);

// Same bytes on both sides, never sent over the wire:
const same = new Uint8Array(clientSecret).every(
  (b, i) => b === new Uint8Array(serverSecret)[i]
);
console.log('shared secrets match:', same);   // true

const appKey = await deriveTrafficKey(clientSecret, 's ap traffic', 'HASH_OF_TRANSCRIPT');
// `appKey` now encrypts the real HTTP bytes — this is the payoff of the handshake.

The load-bearing line is that clientSecret and serverSecret come out identical even though neither private key ever left its owner. An eavesdropper saw both public keys and still cannot compute the secret — that asymmetry is the entire reason the handshake is possible.

Python: deriving the handshake key schedule with HKDF

Here is the actual TLS 1.3 derivation pattern — HKDF-Extract followed by labelled HKDF-Expand-Label calls — implemented against the standard cryptography library primitives:

import hashlib, hmac, struct

def hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
    # RFC 5869: Extract = HMAC(salt, ikm).  Compresses non-uniform DH output
    # into a uniformly pseudorandom secret the same length as the hash.
    if not salt:
        salt = b"\x00" * hashlib.sha256().digest_size
    return hmac.new(salt, ikm, hashlib.sha256).digest()

def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
    # RFC 5869: Expand stretches a secret into `length` bytes, chained HMACs.
    out, t, counter = b"", b"", 1
    while len(out) < length:
        t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
        out += t
        counter += 1
    return out[:length]

def hkdf_expand_label(secret: bytes, label: str, ctx: bytes, length: int) -> bytes:
    # TLS 1.3 (RFC 8446 §7.1) wraps Expand with a structured, version-namespaced label.
    full = b"tls13 " + label.encode()
    info = struct.pack("!H", length)                     # 2-byte output length
    info += bytes([len(full)]) + full                    # length-prefixed label
    info += bytes([len(ctx)]) + ctx                      # length-prefixed context (transcript hash)
    return hkdf_expand(secret, info, length)

def transcript_hash(*messages: bytes) -> bytes:
    h = hashlib.sha256()
    for m in messages:
        h.update(m)
    return h.digest()

# --- The TLS 1.3 key schedule, abbreviated ---
zero = b"\x00" * 32
dh_shared = bytes.fromhex("11" * 32)        # stand-in for the ECDHE output

early_secret     = hkdf_extract(zero, zero)                 # no PSK in a fresh handshake
derived          = hkdf_expand_label(early_secret, "derived", transcript_hash(b""), 32)
handshake_secret = hkdf_extract(derived, dh_shared)         # folds in the DH secret

th = transcript_hash(b"ClientHello...", b"ServerHello...")  # bytes seen so far
c_hs_traffic = hkdf_expand_label(handshake_secret, "c hs traffic", th, 32)
s_hs_traffic = hkdf_expand_label(handshake_secret, "s hs traffic", th, 32)

# c_hs_traffic / s_hs_traffic now key the AEAD that encrypts the Certificate,
# CertificateVerify, and Finished messages — exactly the {braced} flight above.
print("client handshake secret:", c_hs_traffic.hex()[:16], "...")

Notice that handshake_secret is the first place the Diffie-Hellman output enters the chain, and that th (the transcript hash) is mixed into every traffic key. That is the mechanism by which a man-in-the-middle who alters any handshake byte produces a different key on the two ends and is immediately caught.

Variants and modes worth knowing

Session resumption (PSK). After a successful handshake the server can hand the client a NewSessionTicket. On the next connection the client presents it as a pre-shared key, skipping the certificate and signature — a faster, lighter handshake.

0-RTT early data. Built on PSK resumption: the client encrypts application data with a key derived from the ticket and sends it in the first flight, before the server has replied. Saves a full round trip, but the data is replayable, so it is restricted to idempotent requests.

HelloRetryRequest. If the client's speculative key_share used a group the server doesn't support, the server replies with a HelloRetryRequest naming an acceptable group, and the client retries — turning the handshake back into 2-RTT for that connection.

Mutual TLS (mTLS). The server additionally sends a CertificateRequest, and the client presents and signs its own certificate. Standard in service-to-service authentication and zero-trust meshes.

Encrypted Client Hello (ECH). The one remaining plaintext leak is the SNI — the hostname in ClientHello, which reveals which site you're visiting even on a shared IP. ECH encrypts the inner ClientHello under a public key published in DNS, closing that gap.

Common bugs and edge cases

  • Missing intermediate certificate. The server must send its leaf and the intermediate(s) up to (but not including) the root. Forget the intermediate and many clients can't build a chain to a trusted root — the classic "works in my browser, fails in curl" bug.
  • Clock skew. Certificate validity is time-bounded. A device whose clock is wildly wrong will reject a perfectly valid certificate as "not yet valid" or "expired." Common on freshly-booted IoT devices with no RTC.
  • SNI mismatch. On shared hosting the server picks which certificate to present based on the SNI in ClientHello. If the client omits SNI or sends the wrong name, the server returns the default certificate and the name check fails.
  • Treating a valid certificate as authentication. A certificate proves a CA vouched for a key; only the CertificateVerify signature over the live transcript proves the peer holds the matching private key. Code that checks the chain but not the signature (or pins the cert but trusts a replayed handshake) is broken.
  • Sending non-idempotent requests as 0-RTT. Because early data is replayable, a "POST /transfer" sent as 0-RTT can be executed twice by an attacker who replays the flight. Restrict 0-RTT to safe, idempotent methods.
  • Ignoring the downgrade sentinel. A 1.3-capable client that negotiates 1.2 must check ServerHello.random for the DOWNGRD sentinel; skipping that check leaves it open to a forced downgrade by an active attacker.

Frequently asked questions

How many round trips does a TLS handshake take?

TLS 1.3 completes a full handshake in 1 round trip (1-RTT): the client sends its key share in the ClientHello, the server replies with its share plus the certificate and Finished, and the client can send application data with its own Finished. TLS 1.2 needed 2 round trips because the cipher and key share were negotiated in separate flights. A resumed TLS 1.3 session can send data in 0-RTT — with the early-data security caveat that it is replayable.

What is the difference between the TLS handshake and the certificate?

The certificate is a static document that binds a public key to a domain name, signed by a Certificate Authority. The handshake is the live protocol run: the server sends the certificate, then proves it owns the matching private key by signing the running transcript hash in CertificateVerify. A valid certificate alone proves nothing — an attacker could replay it. The signature over the live transcript is what actually authenticates the server.

Why does TLS 1.3 use ephemeral Diffie-Hellman instead of RSA key transport?

RSA key transport had the client encrypt the session secret with the server's long-term public key. If that private key ever leaked, an attacker who recorded old traffic could decrypt all of it. Ephemeral Diffie-Hellman (ECDHE) generates a fresh keypair per connection and throws it away afterward, so a later key compromise cannot decrypt past sessions. This property is called forward secrecy, and TLS 1.3 makes it mandatory by removing static RSA key exchange entirely.

What stops an attacker from downgrading a connection to an older, weaker version?

Two defenses. First, the entire handshake transcript is hashed into the Finished MAC, so any tampered byte makes the MACs disagree and the connection aborts. Second, TLS 1.3 servers that detect a downgrade attempt embed a fixed sentinel value in the last 8 bytes of ServerHello.random ('DOWNGRD\x01'); a 1.3-capable client that sees it knows an attacker forced 1.2 and refuses.

What is the TLS key schedule and why is HKDF used?

The key schedule is the chain of HKDF (HMAC-based Key Derivation Function) operations that turns the raw Diffie-Hellman shared secret into all the keys TLS needs: handshake traffic keys, application traffic keys, the resumption secret, and exporter secrets. HKDF is used because raw DH output is not uniformly random and must be 'extracted' into a fixed-length pseudorandom key, then 'expanded' into many context-labelled keys. Each derivation folds in the transcript hash, binding the keys to exactly this handshake.

Is the TLS handshake itself encrypted?

Partly. The ClientHello and ServerHello are sent in the clear because the keys do not exist yet. But in TLS 1.3, immediately after both key shares are exchanged, the server switches to handshake traffic keys — so the certificate, the CertificateVerify signature, and the Finished message are all encrypted. In TLS 1.2 the certificate was sent in plaintext, which leaked which site you were visiting; TLS 1.3 closed that gap.