How HTTPS Actually Works: Every Byte of the TLS Handshake
Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)Every time you open a webpage, a cryptographic negotiation happens in milliseconds. Your browser and the server perform a key exchange, authenticate each other's identity (well, usually just the server's), agree on an encryption algorithm, and establish a shared secret that no eavesdropper can recover. The whole process completes in a single round trip. Most developers have a vague sense that "HTTPS is encrypted HTTP," and that is roughly where their understanding ends. This post walks through every step of a TLS 1.3 handshake, byte by byte, covering the key exchange math, the certificate validation chain, the cipher negotiation, and all the places where previous versions got it catastrophically wrong.
The Problem: Plaintext HTTP
HTTP without TLS is a postcard. Anyone between you and the server can read it, modify it, and forge responses. This is not hypothetical.
Imagine you are sitting in a coffee shop in Athens, connected to the open WiFi. Your laptop sends an HTTP request to http://bank.example.com/transfer?to=GR1601101250000000012300695&amount=500. That request is a plaintext TCP packet. Every device between you and the server sees it: the WiFi access point, the router, the ISP, every intermediate hop.
An attacker on the same WiFi network (using a tool like bettercap or mitmproxy) can perform ARP spoofing to position themselves as the default gateway. Once they are in the path, they see every byte of every HTTP request and response. They see cookies, session tokens, form submissions, API keys. They can modify responses in transit, injecting JavaScript into pages, redirecting downloads, replacing HTTPS links with HTTP links (the SSLstrip attack).
Here is what a captured HTTP request looks like at the packet level:
00000000 47 45 54 20 2f 61 63 63 6f 75 6e 74 2f 62 61 6c |GET /account/bal|
00000010 61 6e 63 65 20 48 54 54 50 2f 31 2e 31 0d 0a 48 |ance HTTP/1.1..H|
00000020 6f 73 74 3a 20 62 61 6e 6b 2e 65 78 61 6d 70 6c |ost: bank.exampl|
00000030 65 2e 63 6f 6d 0d 0a 43 6f 6f 6b 69 65 3a 20 73 |e.com..Cookie: s|
00000040 65 73 73 69 6f 6e 3d 61 62 63 64 65 66 31 32 33 |ession=abcdef123|
00000050 34 35 36 0d 0a 0d 0a |456....|Everything is readable. The path, the host, the session cookie. The attacker does not need to break any encryption because there is none. TLS exists to make this eavesdropping and tampering impossible, even on a completely untrusted network.
The TLS 1.3 Handshake: Step by Step
TLS 1.3 (RFC 8446, published August 2018) was a ground-up redesign of the handshake. Compared to TLS 1.2, it removed an entire round trip, eliminated insecure options, and encrypted almost everything after the ServerHello. Here is the complete message flow.
ClientHello
The client sends the first message. This is unencrypted (it has to be; there is no shared key yet) and contains everything the server needs to select parameters and compute a shared secret.
The ClientHello includes:
Protocol version. TLS 1.3 uses a legacy version field of 0x0303 (which is TLS 1.2) for backward compatibility, with the actual version negotiated via the supported_versions extension. This avoids breaking middleboxes that choke on unfamiliar version numbers, a practical concession born from years of deployment pain.
Random. 32 bytes of cryptographically secure random data. This contributes entropy to the key derivation and prevents replay attacks.
Cipher suites. An ordered list of supported AEAD cipher suites. In TLS 1.3, the list is short because weak options were removed entirely. A typical client offers:
TLS_AES_128_GCM_SHA256 (0x1301)
TLS_AES_256_GCM_SHA384 (0x1302)
TLS_CHACHA20_POLY1305_SHA256 (0x1303)That is it. No CBC mode, no RC4, no 3DES, no RSA key exchange, no static DH. Every one of those was stripped out.
Supported Groups extension. Lists the elliptic curves (or finite field groups) the client supports for key exchange. Common values:
x25519 (0x001d)
secp256r1 (0x0017)
secp384r1 (0x0018)
x448 (0x001e)Key Share extension. This is the critical innovation. In TLS 1.2, the client had to wait for the server to choose parameters before sending any key material. In TLS 1.3, the client speculatively generates one or more ephemeral key pairs and sends the public keys in the ClientHello. If the server supports one of the offered groups, the handshake can complete in a single round trip. If not, the server sends a HelloRetryRequest asking for a different group.
A typical key share for X25519 is 32 bytes of the client's ephemeral public key.
Server Name Indication (SNI) extension. The hostname the client wants to connect to, sent in plaintext. This is necessary because a single IP address can host thousands of domains, and the server needs to know which certificate to present before the handshake completes. SNI being unencrypted is a known privacy leak; Encrypted Client Hello (ECH), currently being standardized, aims to fix this by encrypting the SNI using a public key published in DNS.
ALPN extension (Application-Layer Protocol Negotiation). Indicates which application protocol the client wants to use over TLS. Typical values: h2 (HTTP/2), http/1.1. This lets the server know the application protocol before the handshake completes, avoiding a separate negotiation step.
Signature Algorithms extension. Lists the signature schemes the client supports for verifying the server's certificate. This includes algorithms like ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, and ed25519.
ServerHello
The server processes the ClientHello and responds with a single message that completes the key agreement.
Selected cipher suite. The server picks one from the client's list. If it cannot support any of them, it aborts the handshake.
Selected key share. The server generates its own ephemeral key pair for the selected group (say, X25519), performs the key exchange computation using the client's public key from the ClientHello, and sends its own public key back. At this point, both sides have the inputs needed to compute the shared secret.
Random. 32 bytes of server random. Combined with the client random and the ECDHE shared secret, these feed into the key derivation function.
After ServerHello, everything changes. Both the client and the server now have enough information to derive handshake traffic keys. From this point forward, every message is encrypted. In TLS 1.2, the handshake messages (Certificate, CertificateVerify, Finished) were sent in the clear. In TLS 1.3, they are encrypted under the handshake traffic keys. This is a significant privacy improvement: the server's certificate (which reveals its identity) is no longer visible to passive observers.
EncryptedExtensions
Sent by the server, encrypted with the handshake traffic key. Contains extensions that are not needed for key establishment and can therefore be protected. ALPN negotiation results go here. SNI acknowledgment goes here. Anything that was not critical for the ServerHello itself gets moved to this encrypted message.
Certificate
The server sends its X.509 certificate chain, encrypted. The chain typically contains:
- The leaf certificate (the server's own cert, binding its public key to its domain name)
- One or more intermediate certificates (issued by a Certificate Authority)
- The root certificate is NOT sent; the client already has it in its trust store
The certificates are DER-encoded and include the server's public key, the domain name(s) in the Subject Alternative Name (SAN) extension, validity dates, the issuer's name, and the digital signature from the issuing CA.
CertificateVerify
The server proves it possesses the private key corresponding to the public key in the certificate. It does this by signing a hash of all handshake messages so far (the transcript hash) with its private key. The client verifies this signature using the public key from the certificate.
This is crucial. Without CertificateVerify, an attacker could simply replay a legitimate server's certificate chain. The signature over the transcript hash proves the server is live, actually participating in this specific handshake, and holds the private key.
Server Finished
A MAC (Message Authentication Code) over the entire handshake transcript, computed using the handshake traffic keys. This lets the client verify that nothing was tampered with during the handshake and that the server derived the same keys.
Client Finished
The client sends its own Finished message, also a MAC over the transcript. At this point, both sides switch to application traffic keys (derived from the same shared secret but through a different branch of the key derivation tree), and encrypted application data can flow.
The Complete Flow
Client Server
ClientHello
+ supported_versions
+ supported_groups
+ key_share (X25519 public key)
+ signature_algorithms
+ server_name (SNI)
+ alpn
-------->
ServerHello
+ selected key_share
+ selected cipher suite
{EncryptedExtensions}
{Certificate}
{CertificateVerify}
{Finished}
<--------
{Finished}
-------->
[Application Data] <-------> [Application Data]
{} = encrypted with handshake traffic keys
[] = encrypted with application traffic keysThe entire handshake is one round trip (1-RTT). The client sends ClientHello, the server responds with everything in a single flight, the client sends Finished, and then application data starts flowing. Compare this to TLS 1.2, which required two round trips (2-RTT) because the key exchange parameters were not sent until the server chose them.
ECDHE Key Exchange: The Math
The "E" in ECDHE stands for Ephemeral. Every handshake generates a new key pair, so compromising a server's long-term private key does not retroactively decrypt past sessions. This property is called forward secrecy (sometimes "perfect forward secrecy"), and it is one of the most important security properties in TLS 1.3. In TLS 1.2 with RSA key exchange (now removed), the session key was encrypted with the server's RSA public key. If someone recorded all traffic and later obtained the server's RSA private key, they could decrypt every recorded session. Forward secrecy eliminates this entire class of attack.
How Diffie-Hellman Works
The Diffie-Hellman key exchange allows two parties to establish a shared secret over an insecure channel. The classic version works over a multiplicative group of integers modulo a prime:
- Alice and Bob agree on a large prime
pand a generatorg(these are public). - Alice picks a secret integer
a, computesA = g^a mod p, and sendsAto Bob. - Bob picks a secret integer
b, computesB = g^b mod p, and sendsBto Alice. - Alice computes
S = B^a mod p = g^(ba) mod p. - Bob computes
S = A^b mod p = g^(ab) mod p.
Both arrive at the same shared secret S, and an eavesdropper who sees A, B, g, and p cannot efficiently compute S without solving the discrete logarithm problem.
Elliptic Curve Diffie-Hellman (ECDH)
ECDH replaces the multiplicative group with the group of points on an elliptic curve. The mathematical operation analogous to exponentiation is scalar multiplication of a point on the curve.
An elliptic curve is defined by an equation of the form:
y² = x³ + ax + b (mod p)Points on the curve form a group under a geometrically defined addition operation. "Adding" two points P and Q means drawing a line through them, finding the third intersection point with the curve, and reflecting it across the x-axis. "Doubling" a point (adding it to itself) uses the tangent line. Scalar multiplication (computing kP for an integer k) is repeated point addition, made efficient via double-and-add algorithms.
The security rests on the Elliptic Curve Discrete Logarithm Problem (ECDLP): given points P and Q = kP on the curve, it is computationally infeasible to determine k. The best known general-purpose algorithms for ECDLP run in approximately O(√n) time (Pollard's rho), where n is the order of the group. For a 256-bit curve, this gives roughly 128 bits of security.
X25519 Specifically
X25519 is the most common key exchange in TLS 1.3 today. It was designed by Daniel J. Bernstein and uses Curve25519, a Montgomery curve defined over the prime field p = 2^255 - 19 (hence the name). The curve equation is:
y² = x³ + 486662x² + x (mod 2^255 - 19)X25519 has several properties that make it attractive for TLS:
Speed. The Montgomery ladder algorithm for scalar multiplication runs in constant time with respect to the scalar, making it naturally resistant to timing side-channel attacks. On modern x86 hardware, an X25519 operation takes roughly 120,000 cycles.
Simplicity. X25519 was designed to be hard to misuse. Private keys are 32 random bytes (with a few bits clamped for mathematical reasons). Public keys are 32 bytes. The shared secret is 32 bytes. There are no parameters to choose, no curves to negotiate, no point format ambiguity.
Security margin. Curve25519 offers roughly 128 bits of security against classical attacks. The prime 2^255 - 19 was chosen specifically for efficiency in modular arithmetic on 64-bit processors (Mersenne-like primes allow fast reduction).
The key exchange proceeds as follows:
- The client generates a 32-byte random private key
a, computes the public keyA = a * G(whereGis the curve's base point), and sendsAin the ClientHello key_share extension. - The server generates a 32-byte random private key
b, computesB = b * G, and sendsBin the ServerHello key_share. - The client computes the shared secret
S = a * B = a * b * G. - The server computes
S = b * A = b * a * G.
Both arrive at the same point, and the x-coordinate of that point is the raw shared secret. This shared secret feeds into the HKDF (HMAC-based Key Derivation Function) to derive the actual traffic encryption keys.
# Demonstrating X25519 key exchange with Python's cryptography library
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
# Client generates ephemeral key pair
client_private = X25519PrivateKey.generate()
client_public = client_private.public_key()
# Server generates ephemeral key pair
server_private = X25519PrivateKey.generate()
server_public = server_private.public_key()
# Both sides compute the shared secret
client_shared = client_private.exchange(server_public)
server_shared = server_private.exchange(client_public)
# They are identical
assert client_shared == server_shared
print(f"Shared secret: {client_shared.hex()}")
print(f"Length: {len(client_shared)} bytes")Key Derivation: HKDF
The raw ECDHE shared secret is never used directly as an encryption key. TLS 1.3 uses HKDF (RFC 5869) in a carefully structured key schedule to derive multiple keys from the shared secret:
- Early Secret: Derived from a Pre-Shared Key (or zero if no PSK). Used for 0-RTT data.
- Handshake Secret: Derived from the ECDHE shared secret and the early secret. Produces the handshake traffic keys used to encrypt Certificate, CertificateVerify, and Finished.
- Master Secret: Derived from the handshake secret. Produces application traffic keys, exporter keys, and resumption keys.
Each derivation step uses HKDF-Extract (to mix entropy from a new source) and HKDF-Expand-Label (to derive specific-purpose keys with domain separation). The transcript hash (a running hash of all handshake messages) is mixed in at each stage, binding the keys to the specific handshake that produced them.
Certificate Chains: The Trust Model
The key exchange gives us encryption and forward secrecy. But encryption alone does not prevent a man-in-the-middle attack. If an attacker interposes between client and server, they could perform separate key exchanges with each side, decrypting and re-encrypting everything in transit. The client needs to verify that the server is who it claims to be. This is where X.509 certificates and the chain of trust come in.
X.509 Certificate Structure
An X.509 v3 certificate contains, at minimum:
- Version: Always v3 (integer value 2) for modern certificates
- Serial Number: A unique identifier assigned by the CA
- Signature Algorithm: The algorithm the CA used to sign this certificate (e.g.,
sha256WithRSAEncryption,ecdsa-with-SHA256) - Issuer: The distinguished name of the CA that issued this certificate
- Validity: Not Before and Not After timestamps
- Subject: The distinguished name of the entity this certificate represents
- Subject Public Key Info: The algorithm and public key of the certificate holder
- Extensions: The critical part for modern TLS
The most important extensions are:
Subject Alternative Name (SAN). Lists the domain names (and optionally IP addresses) the certificate is valid for. Modern browsers use SAN exclusively and ignore the Subject Common Name (CN) field for domain validation. A certificate for debtman.dev will have a SAN entry of DNS:debtman.dev and possibly DNS:*.debtman.dev for wildcard coverage.
Key Usage / Extended Key Usage. Constrains what the public key can be used for. A leaf certificate will have serverAuth in its Extended Key Usage; a CA certificate will have keyCertSign and cRLSign.
Basic Constraints. Indicates whether this certificate is a CA certificate (can issue other certificates) or an end-entity certificate. The CA:TRUE flag, along with a path length constraint, prevents leaf certificates from being used to sign other certificates.
Authority Information Access (AIA). Contains the URL for OCSP responder (for revocation checking) and the URL to download the issuing CA's certificate.
The Chain of Trust
Certificate validation works by building a chain from the leaf certificate to a trusted root:
- Leaf certificate: Presented by the server. Signed by an intermediate CA.
- Intermediate certificate(s): Signed by the root CA (or by another intermediate). One or more intermediates may be in the chain.
- Root certificate: Self-signed. Pre-installed in the client's trust store (the operating system or browser ships with a set of ~150 root certificates).
The client validates each link: check the signature, check the validity dates, check the basic constraints, check that the leaf's SAN matches the requested hostname. If any check fails, the handshake is aborted with a certificate error.
Why the intermediate layer? Root CA private keys are extraordinarily valuable and are kept offline in hardware security modules (HSMs) in physically secured vaults. Intermediate CAs handle day-to-day certificate issuance. If an intermediate CA's key is compromised, the root CA can revoke just that intermediate certificate, limiting the damage. If a root key were compromised, the entire trust chain under that root collapses.
You can examine a certificate chain with openssl:
openssl s_client -connect debtman.dev:443 -showcerts </dev/null 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# Output (example):
# subject=CN=debtman.dev
# issuer=C=US, O=Let's Encrypt, CN=R11
# notBefore=Mar 15 00:00:00 2026 GMT
# notAfter=Jun 13 23:59:59 2026 GMT
# X509v3 Subject Alternative Name:
# DNS:debtman.devCertificate Transparency (CT)
Certificate Transparency is a system of public, append-only logs that record every certificate issued by participating CAs. The purpose is to detect misissued certificates (whether through CA compromise, human error, or malicious intent).
When a CA issues a certificate, it submits it to one or more CT logs. The log returns a Signed Certificate Timestamp (SCT), which is a promise that the certificate will appear in the log within a specified time period (the Maximum Merge Delay, typically 24 hours). The SCT can be embedded in the certificate itself (via an X.509 extension), delivered via a TLS extension during the handshake, or stapled via OCSP.
Chrome requires all publicly trusted certificates to have SCTs from at least two independent CT logs. This means that if a CA issues a rogue certificate for google.com (as happened with DigiNotar in 2011), it will appear in the CT logs and be detectable. Organizations can monitor CT logs for certificates issued for their domains and raise alerts on unauthorized issuance.
Revocation: OCSP Stapling vs. CRL
What happens when a private key is compromised and a certificate needs to be revoked before its expiration date?
Certificate Revocation Lists (CRLs) were the original mechanism. The CA publishes a list of revoked certificate serial numbers at a URL specified in the certificate. The client downloads the CRL and checks if the certificate's serial number appears on it. The problems: CRLs can be large (millions of entries for a major CA), they are updated infrequently, and downloading them adds latency. Most browsers stopped checking CRLs years ago because the failure mode was unacceptable; if the CRL check failed (network error, timeout), browsers would "soft-fail" and accept the certificate anyway, making the check security theater.
OCSP (Online Certificate Status Protocol) improved on CRLs by providing a per-certificate query. The client sends the certificate's serial number to the CA's OCSP responder and gets a signed response: "good," "revoked," or "unknown." Better than downloading the entire CRL, but still has problems. It adds a round trip to every new TLS connection (the client must contact the OCSP responder before proceeding). It leaks browsing history to the CA (the CA's OCSP responder sees every domain you visit). And the same soft-fail problem applies.
OCSP Stapling (formally "TLS Certificate Status Request") solves most of these issues. The server periodically fetches its own OCSP response from the CA, and "staples" it to the TLS handshake (in the Certificate message or via a status_request extension). The client gets the signed OCSP response directly from the server, eliminating the extra round trip and the privacy leak. The response is signed by the CA, so the server cannot forge it. The response has a limited validity period (typically 7 days), so it must be refreshed regularly.
OCSP Must-Staple is a certificate extension that tells the client: "This certificate must always be accompanied by a stapled OCSP response. If you don't get one, reject the certificate." This closes the soft-fail loophole for certificates that opt in.
AEAD Ciphers: AES-GCM and ChaCha20-Poly1305
TLS 1.3 exclusively uses AEAD (Authenticated Encryption with Associated Data) ciphers. Every previous mode of operation has been removed. Understanding why requires understanding what went wrong with the alternatives.
What AEAD Means
An AEAD cipher provides two properties simultaneously:
- Confidentiality: The plaintext is encrypted; an attacker cannot read it.
- Integrity/Authentication: Any modification to the ciphertext (or to specified "associated data" that is not encrypted but must be authenticated) is detected. The decryption operation fails entirely if the data has been tampered with.
The "associated data" in TLS is the record header (content type, protocol version, length), which is sent in the clear but must not be modified without detection.
Before AEAD, TLS used separate encryption and MAC operations: encrypt with AES-CBC, then compute HMAC-SHA256 over the ciphertext (or, in some configurations, MAC-then-encrypt). This composition is fragile. The ordering matters, the padding scheme matters, and the error handling matters. Getting any of it wrong produces vulnerabilities (as POODLE, BEAST, and Lucky13 demonstrated).
AEAD ciphers are a single primitive that handles both operations atomically. There is no padding oracle, no MAC-timing attack, no room for encrypt-then-MAC vs. MAC-then-encrypt confusion.
AES-128-GCM
AES-128-GCM (Galois/Counter Mode) is the most widely used cipher suite in TLS 1.3. It combines AES-128 in counter mode (CTR) for encryption with GHASH (a polynomial hash over GF(2^128)) for authentication.
The encryption works as follows:
- A 96-bit nonce (IV) is constructed. In TLS 1.3, this is derived from the traffic key material and XORed with the record sequence number, ensuring uniqueness per record.
- AES encrypts the nonce to produce a keystream block. The nonce is incremented for each subsequent block.
- The keystream is XORed with the plaintext to produce ciphertext (this is standard counter mode).
- The GHASH function computes an authentication tag over the AAD (Additional Authenticated Data) and the ciphertext using polynomial multiplication in GF(2^128).
- The final authentication tag is appended to the ciphertext.
On decryption, the receiver recomputes the authentication tag. If it does not match, the entire record is rejected. There is no partial decryption, no error message that leaks information about which byte was wrong.
The nonce problem. AES-GCM's security depends critically on nonce uniqueness. If the same nonce is ever reused with the same key, the security collapses completely. XORing two ciphertexts encrypted with the same keystream cancels the keystream, revealing the XOR of the two plaintexts, and the GHASH authentication key can be recovered. In TLS 1.3, nonce reuse is prevented by construction (the nonce incorporates the sequence number, which is a monotonically increasing counter), but in other contexts (particularly when implementers roll their own nonce management), GCM nonce reuse is a serious and recurring bug class.
ChaCha20-Poly1305
ChaCha20-Poly1305 (RFC 8439) is the alternative AEAD cipher in TLS 1.3, designed by Daniel J. Bernstein. It pairs the ChaCha20 stream cipher with the Poly1305 message authentication code.
Why does it exist if we have AES-GCM? Performance on devices without hardware AES support. Modern x86 and ARM processors include dedicated AES-NI and AES-CE instructions that make AES-GCM extremely fast (often exceeding 10 GB/s). But older ARM processors (pre-ARMv8), many IoT devices, and some mobile chipsets lack these instructions. Software AES is slow and vulnerable to cache-timing side-channel attacks. ChaCha20 is a pure ARX (Add-Rotate-XOR) cipher that runs efficiently in software on any architecture and is inherently resistant to timing attacks because it uses no lookup tables or data-dependent branches.
On a modern x86 server in a Berlin data centre with AES-NI, AES-128-GCM will be faster. On a low-end smartphone in Barcelona without hardware AES, ChaCha20-Poly1305 can be three to four times faster. Google deployed ChaCha20-Poly1305 in Chrome specifically for mobile performance and found measurable improvements in page load times on Android devices.
Why CBC Mode Was Removed
In TLS 1.2 and earlier, AES-CBC (Cipher Block Chaining) with HMAC was the standard cipher configuration. CBC mode works by XORing each plaintext block with the previous ciphertext block before encrypting. This requires padding the plaintext to a multiple of the block size (16 bytes for AES), typically using PKCS#7 padding.
The problem: padding is checked after decryption, and the way implementations handled padding errors created an oracle. If the server returned a different error for "bad padding" versus "bad MAC," an attacker could manipulate the ciphertext one byte at a time and observe the error to determine whether the padding was valid. By iterating, they could decrypt the entire ciphertext without knowing the key. This is the padding oracle attack, and it has been discovered and rediscovered in various forms in TLS (POODLE), ASP.NET, Java, and dozens of other systems.
TLS 1.3 eliminated CBC entirely. There is no padding to exploit. AEAD ciphers produce a single pass/fail result on authentication, leaking nothing about which part of the ciphertext was invalid.
TLS 1.2 vs. TLS 1.3: What Changed and Why
TLS 1.3 is not an incremental update. It is a deliberate stripping-out of everything that was broken or unnecessary. Here is what was removed.
RSA key exchange. In TLS 1.2, the client could encrypt the premaster secret with the server's RSA public key. This worked, but it provided no forward secrecy. If the server's RSA key was later compromised, all recorded sessions could be decrypted. RSA key exchange was the default for most of TLS's history, and its removal is the single most impactful change in TLS 1.3.
Static Diffie-Hellman. TLS 1.2 allowed DH key exchange with static (non-ephemeral) server keys. Same problem as RSA: no forward secrecy.
CBC cipher suites. Removed entirely for the reasons described above.
RC4. Already deprecated by RFC 7465 in 2015 due to known biases in the RC4 keystream that allowed plaintext recovery.
3DES. Vulnerable to the Sweet32 birthday attack. With a 64-bit block size, after 2^32 blocks (roughly 32 GB of data under a single key), block collisions become probable and can leak plaintext.
Compression. TLS-level compression (DEFLATE) was removed because of the CRIME and BREACH attacks (compression oracle attacks that can extract secrets like session cookies from compressed encrypted streams).
Renegotiation. TLS 1.2 allowed mid-connection renegotiation (changing cipher suites, requesting client certificates). This was a source of complexity and vulnerability (the renegotiation attack of 2009, patched by RFC 5746). TLS 1.3 removes renegotiation entirely. If the server needs a client certificate, it uses the post-handshake authentication mechanism instead.
The handshake round trip. TLS 1.2 required 2-RTT:
Client: ClientHello
Server: ServerHello, Certificate, ServerKeyExchange, ServerHelloDone
Client: ClientKeyExchange, ChangeCipherSpec, Finished
Server: ChangeCipherSpec, FinishedTLS 1.3 requires 1-RTT:
Client: ClientHello (with key_share)
Server: ServerHello, {EncryptedExtensions, Certificate, CertificateVerify, Finished}
Client: {Finished}The saving comes from the client speculatively sending key shares. The server can complete the key exchange and send all its handshake messages (encrypted) in a single flight. On a typical connection between Athens and a server in Frankfurt (roughly 30ms RTT), this saves 30ms per new connection. At scale, across billions of connections, that matters.
ChangeCipherSpec. This message existed in TLS 1.2 to signal the transition from unencrypted to encrypted communication. TLS 1.3 does not need it because the transition point is implicit (everything after ServerHello is encrypted). A dummy ChangeCipherSpec message is sometimes sent for middlebox compatibility, but it is semantically meaningless.
0-RTT Resumption: Speed at a Cost
TLS 1.3 introduces a mechanism for sending application data in the very first flight of a resumed connection, eliminating even the single round trip of the normal handshake. This is called 0-RTT or "early data."
How It Works
After a successful TLS 1.3 handshake, the server sends the client a session ticket (encrypted with a key only the server knows). The ticket contains enough state to resume the session: the negotiated cipher suite, the resumption master secret, and a ticket lifetime.
On the next connection to the same server, the client sends a ClientHello that includes the session ticket (in a pre_shared_key extension) and immediately sends early application data encrypted with a key derived from the resumption master secret. The server can process this data before the handshake completes.
The Replay Problem
0-RTT data has a fundamental security limitation: it is not protected against replay attacks. An attacker who records the ClientHello and the 0-RTT data can replay the entire flight to the server. The server may process the early data again.
For idempotent requests (like a GET request for a static page), this is acceptable. The worst case is the server serves the same page twice. For non-idempotent requests (like a POST that transfers €1,000 from your account), replay is devastating. The attacker replays the request ten times, and the server transfers €10,000.
TLS 1.3 explicitly warns implementers about this. Servers are supposed to implement replay protections (like single-use ticket nonces or time windows), but these are application-level defenses and are not guaranteed by the protocol itself. Many security-conscious deployments disable 0-RTT entirely for this reason. Cloudflare, for example, only allows 0-RTT for GET requests to specific endpoints.
There is also a weaker security property: 0-RTT data does not have forward secrecy with respect to the resumption secret. If the session ticket key is compromised, an attacker can decrypt the 0-RTT data. The 1-RTT handshake data, established via fresh ECDHE, does retain forward secrecy.
Broken History: Every Major TLS Vulnerability
The history of TLS is a history of discovered flaws. Each vulnerability teaches something about protocol design.
SSLv3 and POODLE (2014)
POODLE (Padding Oracle On Downgraded Legacy Encryption) exploited the padding scheme in SSLv3's CBC mode. SSLv3 specified that the padding bytes could be arbitrary (only the last byte indicating the padding length was checked). An attacker could modify padding bytes, observe whether the server accepted or rejected the record, and use this oracle to decrypt one byte of plaintext per 256 attempts. Combined with a protocol downgrade attack (forcing a TLS connection to fall back to SSLv3), this was a practical attack against any server that still supported SSLv3.
The fix: kill SSLv3 entirely (RFC 7568) and add the TLS_FALLBACK_SCSV mechanism to prevent version downgrade attacks.
BEAST (2011)
BEAST (Browser Exploit Against SSL/TLS) attacked CBC mode in TLS 1.0. In TLS 1.0, the initialization vector (IV) for each record was the last ciphertext block of the previous record, making it predictable. An attacker could inject chosen plaintext (via JavaScript in the victim's browser), observe the resulting ciphertext, and use the predictable IV to perform a blockwise adaptive chosen-plaintext attack.
The mitigation was a hack: implementations inserted a 1-byte record before each real record (the "1/n-1 split"), breaking the IV predictability at the cost of an extra record per application data write. TLS 1.1 fixed the root cause by using explicit, random IVs for each record.
Heartbleed (2014)
Heartbleed was not a TLS protocol vulnerability. It was a buffer over-read bug in OpenSSL's implementation of the TLS Heartbeat extension (RFC 6520). The Heartbeat extension lets one side send a "ping" message with a payload and a claimed payload length. The other side is supposed to echo the payload back. OpenSSL trusted the claimed length without checking it against the actual payload size. An attacker could send a Heartbeat request with a 1-byte payload but claim it was 65,535 bytes. OpenSSL would copy 65,535 bytes from its process memory (including private keys, session data, and user credentials) into the response.
The bug existed in OpenSSL 1.0.1 through 1.0.1f and affected an estimated 17% of all HTTPS servers at the time of disclosure. It was assigned CVE-2014-0160 and is arguably the most impactful single implementation bug in the history of internet security. Private keys had to be revoked and reissued. Certificates had to be replaced. There was (and remains) no way to know whether a given server was exploited before the fix was deployed.
CRIME and BREACH (2012/2013)
CRIME (Compression Ratio Info-leak Made Easy) exploited TLS-level compression. When a secret (like a session cookie) is compressed alongside attacker-controlled input, the compressed size leaks information about whether the attacker's input matches the secret. By iterating through possible cookie values and observing the compressed ciphertext length, an attacker can extract the cookie one character at a time.
BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) applied the same principle to HTTP-level compression (gzip), which is still widely used. BREACH is harder to mitigate because HTTP compression is important for performance. Mitigations include per-request CSRF tokens (so the secret changes on every request), length hiding (adding random padding to responses), and separating secrets from user-controlled content.
TLS 1.3 removed TLS-level compression entirely. HTTP compression remains a concern, and BREACH-style attacks are still theoretically possible.
DROWN (2016)
DROWN (Decrypting RSA with Obsolete and Weakened eNcryption) exploited servers that supported SSLv2, even if they also supported TLS 1.2. If a server's RSA key was exposed to SSLv2 on any port (even a different service on the same machine), an attacker could use SSLv2's weak export-grade cryptography to perform a Bleichenbacher-style adaptive chosen-ciphertext attack against the RSA key. This could decrypt a captured TLS 1.2 session in roughly 8 hours of computation.
DROWN affected an estimated 33% of all HTTPS servers because many administrators left SSLv2 enabled on secondary services (like SMTP) that shared the same RSA key. The lesson: if any service using a given key supports weak cryptography, all services using that key are compromised.
ROBOT (2017)
ROBOT (Return Of Bleichenbacher's Oracle Threat) demonstrated that the Bleichenbacher RSA padding oracle attack (originally described in 1998) was still present in major TLS implementations nearly 20 years later. RSA PKCS#1 v1.5 padding was never designed to be secure against adaptive chosen-ciphertext attacks, and implementations kept getting the error handling wrong, creating timing oracles that allowed decryption.
TLS 1.3's removal of RSA key exchange eliminates this entire attack class permanently.
Certificate Pinning and HSTS
HTTP Strict Transport Security (HSTS)
HSTS (RFC 6797) is a response header that tells the browser: "For the next N seconds, only connect to this domain over HTTPS. If the certificate is invalid, do not allow the user to bypass the warning. Do not even attempt HTTP."
The header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadWithout HSTS, an attacker can perform an SSLstrip attack: intercept the initial HTTP request (before the redirect to HTTPS), serve a plaintext page that looks identical, and relay requests to the real server over HTTPS. The user never sees HTTPS in their browser bar, but everything looks normal. HSTS prevents this by ensuring the browser never makes a plaintext request after the first visit.
The weakness: the first visit is still vulnerable. If you have never visited debtman.dev before, your browser does not know it requires HTTPS, and the first request might be HTTP. The HSTS preload list closes this gap: browser vendors maintain a hardcoded list of domains that must always be accessed over HTTPS, even on the first visit. Domains can be submitted to hstspreload.org for inclusion.
The .dev TLD. Google (which owns the .dev TLD through its Google Registry subsidiary) added the entire .dev TLD to the HSTS preload list. Every single .dev domain, including debtman.dev, is hardcoded in Chrome, Firefox, Safari, and Edge as HTTPS-only. This means it is physically impossible to access any .dev domain over plaintext HTTP in any modern browser. The browser will refuse to even attempt the connection. This is the strongest possible HSTS guarantee: not just a header that can be missed on the first visit, but a TLD-level policy baked into browser source code.
Certificate Pinning (and Why HPKP Failed)
Certificate pinning restricts which certificates are accepted for a domain. Instead of trusting any certificate issued by any of the ~150 root CAs in the browser's trust store, pinning says: "Only accept certificates that chain to this specific public key (or this specific intermediate, or this specific root)."
HTTP Public Key Pinning (HPKP) was the standardized approach (RFC 7469). The server sent a header listing the SHA-256 hashes of acceptable public keys, along with a max-age value:
Public-Key-Pins: pin-sha256="base64+hash1"; pin-sha256="base64+hash2"; max-age=5184000HPKP was deprecated and removed from Chrome in 2018. The reasons were severe:
- Bricking risk. If a site pinned to a specific key and then lost that key (hardware failure, CA migration, operational error), the site became completely inaccessible to any user who had cached the pin. The only options were to wait for the pin to expire (up to 60 days) or get the domain added to a browser blocklist. Several high-profile sites accidentally bricked themselves.
- RansomPin attacks. An attacker who could temporarily compromise a server could set a malicious pin (pointing to a key they controlled), then hold the domain hostage: "Pay me or your site stays bricked for 60 days."
- Complexity. Operating HPKP correctly required maintaining backup pins, planning for key rotation, and understanding the interaction with CT and OCSP. Most site operators could not do this reliably.
The replacement is Certificate Transparency. Rather than restricting which certificates are accepted (pinning), CT ensures that all issued certificates are publicly logged and auditable. This provides detection rather than prevention, but it is operationally safer.
Practical Examples
Inspecting a TLS Handshake with openssl
The openssl s_client command is the standard tool for examining TLS connections from the command line:
# Full handshake inspection
openssl s_client -connect debtman.dev:443 -tls1_3 -tlsextdebug -msg 2>&1 | head -80
# Show the negotiated cipher suite and protocol
echo | openssl s_client -connect debtman.dev:443 2>/dev/null | \
grep -E '(Protocol|Cipher|Server public key)'
# Example output:
# Protocol : TLSv1.3
# Cipher : TLS_AES_256_GCM_SHA384
# Server public key is 256 bit (ECDSA)
# Examine the full certificate chain
openssl s_client -connect debtman.dev:443 -showcerts </dev/null 2>/dev/null | \
awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ print }' | \
while openssl x509 -noout -subject -issuer 2>/dev/null; do echo "---"; done
# Check certificate expiration
echo | openssl s_client -connect debtman.dev:443 2>/dev/null | \
openssl x509 -noout -dates
# Verify OCSP stapling
openssl s_client -connect debtman.dev:443 -status </dev/null 2>/dev/null | \
grep -A 5 "OCSP Response"Examining Certificate Details with Python
import ssl
import socket
import json
from datetime import datetime, timezone
def inspect_tls_connection(hostname: str, port: int = 443) -> dict:
"""Connect to a host and extract TLS/certificate details."""
context = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls_sock:
cert = tls_sock.getpeercert()
cipher = tls_sock.cipher()
version = tls_sock.version()
# Parse certificate fields
subject = dict(x[0] for x in cert.get('subject', ()))
issuer = dict(x[0] for x in cert.get('issuer', ()))
san = [entry[1] for entry in cert.get('subjectAltName', ())]
not_before = datetime.strptime(
cert['notBefore'], '%b %d %H:%M:%S %Y %Z'
)
not_after = datetime.strptime(
cert['notAfter'], '%b %d %H:%M:%S %Y %Z'
)
days_remaining = (not_after - datetime.now()).days
return {
'hostname': hostname,
'tls_version': version,
'cipher_suite': cipher[0],
'cipher_bits': cipher[2],
'subject_cn': subject.get('commonName'),
'issuer_org': issuer.get('organizationName'),
'issuer_cn': issuer.get('commonName'),
'san_entries': san,
'not_before': not_before.isoformat(),
'not_after': not_after.isoformat(),
'days_until_expiry': days_remaining,
'serial_number': cert.get('serialNumber'),
'ocsp_urls': [
url for method, url in cert.get('OCSP', [])
] if 'OCSP' in cert else [],
}
# Inspect a connection
info = inspect_tls_connection('debtman.dev')
for key, value in info.items():
print(f"{key}: {value}")
# Output (example):
# hostname: debtman.dev
# tls_version: TLSv1.3
# cipher_suite: TLS_AES_256_GCM_SHA384
# cipher_bits: 256
# subject_cn: debtman.dev
# issuer_org: Let's Encrypt
# issuer_cn: R11
# san_entries: ['debtman.dev']
# not_before: 2026-03-15T00:00:00
# not_after: 2026-06-13T23:59:59
# days_until_expiry: 71
# serial_number: 04:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88Reading a TLS 1.3 Wireshark Capture
When you capture a TLS 1.3 handshake in Wireshark, here is what you will and will not see.
Visible in the clear:
- The ClientHello and ServerHello (including SNI, cipher suites, key shares, supported versions)
- The TCP handshake (SYN, SYN-ACK, ACK) before TLS starts
- Record layer headers (content type, record version, length)
- The certificate if you provide the server's private key to Wireshark for decryption (Wireshark can derive session keys from a key log file)
Not visible without keys:
- EncryptedExtensions, Certificate, CertificateVerify, Finished (all encrypted after ServerHello)
- All application data
- Session tickets
To decrypt TLS 1.3 in Wireshark, you need the session keys. Set the SSLKEYLOGFILE environment variable before launching your browser:
export SSLKEYLOGFILE=/tmp/tls-keys.log
firefox https://debtman.dev &Then in Wireshark, go to Edit > Preferences > Protocols > TLS > (Pre)-Master-Secret log filename, and point it at /tmp/tls-keys.log. Wireshark will use the logged keys to decrypt the captured traffic, showing you the full plaintext of every encrypted message.
The key log file format contains lines like:
CLIENT_HANDSHAKE_TRAFFIC_SECRET <client_random_hex> <secret_hex>
SERVER_HANDSHAKE_TRAFFIC_SECRET <client_random_hex> <secret_hex>
CLIENT_TRAFFIC_SECRET_0 <client_random_hex> <secret_hex>
SERVER_TRAFFIC_SECRET_0 <client_random_hex> <secret_hex>Each line corresponds to a specific key in the TLS 1.3 key schedule. The client_random field matches the random value in the ClientHello, letting Wireshark associate the keys with the correct session.
Important: Never use SSLKEYLOGFILE in production. It logs the keys that protect all your TLS connections. Anyone with access to this file can decrypt all captured traffic from that browser session.
Mutual TLS (mTLS)
Standard TLS is asymmetric in its authentication: the server presents a certificate and proves its identity, but the client is anonymous (at the TLS level; authentication happens at the application layer via passwords, tokens, etc.). Mutual TLS extends this by requiring the client to also present a certificate and prove possession of its private key.
How mTLS Differs
The handshake flow is nearly identical to standard TLS 1.3, with two additions:
- The server sends a CertificateRequest message (after EncryptedExtensions) indicating that it requires a client certificate and specifying which CAs and signature algorithms are acceptable.
- The client responds with its own Certificate and CertificateVerify messages (before the client Finished), proving it holds a valid certificate issued by an acceptable CA.
Client Server
ClientHello
-------->
ServerHello
{EncryptedExtensions}
{CertificateRequest} <-- NEW
{Certificate}
{CertificateVerify}
{Finished}
<--------
{Certificate} <-- NEW
{CertificateVerify} <-- NEW
{Finished}
-------->
[Application Data] <-------> [Application Data]Use Cases
Kubernetes. Service meshes like Istio and Linkerd use mTLS to authenticate all inter-service communication within a cluster. Every pod gets a short-lived certificate (typically valid for 24 hours) issued by an internal CA (like cert-manager or SPIFFE/SPIRE). When Service A calls Service B, both sides present certificates. The mesh proxy (Envoy, typically) handles the mTLS handshake transparently. This provides strong identity for every service without application-level changes.
API authentication. Instead of API keys (which are bearer tokens that can be stolen and replayed), mTLS binds authentication to a cryptographic key pair. Financial APIs in the EU (particularly under PSD2 regulations) often require mTLS for bank-to-bank communication, using qualified certificates (QSealC/QWAC) issued by trust service providers under the eIDAS framework.
Zero-trust networks. In a zero-trust architecture, network location (being "inside the firewall") grants no trust. Every connection must be authenticated. mTLS provides transport-level authentication that is independent of application-level credentials, adding a layer of defense. Google's BeyondCorp architecture, which treats the corporate network as untrusted, relies heavily on device certificates and mTLS.
Operational Complexity
mTLS is powerful but operationally demanding. You need:
- A private CA to issue client certificates (and the infrastructure to run it securely)
- A certificate distribution mechanism (how does each client or service get its certificate?)
- A revocation mechanism (what happens when a client certificate is compromised?)
- Short-lived certificates (to limit the window of compromise, but this requires automated renewal)
- Monitoring (certificate expiry is one of the most common causes of production incidents)
The tooling has improved significantly. SPIFFE (Secure Production Identity Framework for Everyone) provides a standard for service identity, and SPIRE is its production implementation. These systems automate certificate issuance, rotation, and validation for large-scale service meshes.
The Full Picture
Now trace the complete lifecycle of a single HTTPS request from your laptop in Athens to a server in Paris.
- DNS resolution. Your browser resolves the hostname to an IP address (which itself may use DNS-over-HTTPS for privacy, creating a TLS-within-TLS situation).
- TCP handshake. SYN, SYN-ACK, ACK. Three packets, one round trip (~25ms to Paris).
- ClientHello. Your browser sends supported cipher suites, X25519 public key, SNI, ALPN. One packet.
- ServerHello + encrypted flight. The server responds with its X25519 public key, then immediately sends EncryptedExtensions, its certificate chain, CertificateVerify, and Finished, all encrypted with handshake keys derived from the ECDHE shared secret. One flight.
- Client verification. Your browser verifies the certificate chain (checking signatures, validity dates, SAN match, CT SCTs, OCSP staple). If anything fails, the connection is aborted.
- Client Finished. Your browser sends its Finished message, encrypted. Both sides switch to application traffic keys.
- HTTP request. Your browser sends
GET / HTTP/2(or HTTP/3 over QUIC) encrypted with the application traffic key. The server decrypts it, generates a response, and sends it back encrypted.
Total overhead for the TLS handshake: one round trip (roughly 25ms to Paris) plus the computation time for X25519 (microseconds on modern hardware), certificate verification (a few milliseconds for signature checks), and symmetric key derivation (microseconds). On a resumed connection with 0-RTT, the overhead drops to effectively zero added latency.
The entire system, from the elliptic curve math to the certificate chain to the AEAD ciphers, exists to solve one problem: letting two machines that have never communicated before establish a private, authenticated channel over a public network. It works so well that most developers never think about it. But now you know what those milliseconds contain.