Securing the Wire in Milliseconds#

Before any HTTP byte can be sent over HTTPS, client and server perform a TLS handshake: a short negotiation that agrees on a protocol version and algorithms, authenticates the server with a certificate, and derives fresh symmetric keys. It takes one network round trip in TLS 1.3, and when it fails you get some of the most common errors in all of networking. The same handshake protects SMTP, IMAP, database connections, DNS over TLS and anything else wrapped in TLS.

The TLS 1.3 Handshake, Message by Message#

Client                                              Server
ClientHello
  + supported versions, cipher suites
  + key_share (ephemeral public key)
  + server_name (SNI), ALPN (h2, http/1.1)  -------->
                                                    ServerHello
                                                      + chosen cipher suite
                                                      + key_share
                                              {EncryptedExtensions}  (ALPN result)
                                              {Certificate}
                                              {CertificateVerify}
                                    <-------- {Finished}
{Finished}                          -------->
[HTTP request]                      <------->  [HTTP response]

Braces mark messages that are already encrypted.

  1. ClientHello. The client lists the TLS versions and cipher suites it supports, a random value, and extensions. Three extensions matter most: SNI (the hostname it wants, so one IP can host many certificates), ALPN (which application protocol it wants to speak, e.g. h2), and key_share, an ephemeral elliptic-curve Diffie-Hellman public key, sent speculatively so no extra round trip is needed.
  2. ServerHello. The server picks a version and cipher suite and returns its own key_share. Both sides can now compute the same shared secret with ECDHE, and everything after this point is encrypted with keys derived from it.
  3. Certificate and CertificateVerify. The server sends its certificate chain and a signature over the handshake transcript made with the certificate's private key. That signature proves the server actually holds the key, not just a copy of the public certificate.
  4. Finished. Each side sends a MAC over the whole transcript. If an attacker altered any earlier message (for example, stripping strong cipher suites from the ClientHello), the Finished values will not match and the connection aborts.

If the server does not support the curve the client guessed for its key_share, it replies with a HelloRetryRequest naming one it does support, costing one extra round trip.

Authentication: What the Client Checks#

The client validates the certificate chain before trusting anything:

  • Chain of trust: leaf certificate, signed by an intermediate CA, signed by a root CA that is in the client's trust store. The server must send the intermediates; the root is already on the client.
  • Name: the requested hostname must match an entry in the certificate's Subject Alternative Name list (a wildcard *.example.com covers one label only: api.example.com, not a.b.example.com).
  • Validity period: notBefore and notAfter must include the current time, which is why a wrong system clock breaks HTTPS.
  • Key usage and revocation: the certificate must be allowed for server authentication; clients may consult revocation data, often via an OCSP staple the server includes.

The asymmetric keys in the certificate only authenticate. Bulk data is encrypted with fast symmetric AEAD ciphers such as AES-GCM or ChaCha20-Poly1305. See Digital Signatures and Encryption for the primitives.

Forward Secrecy and What Changed from TLS 1.2#

In TLS 1.2, a client could use RSA key transport: it encrypted a pre-master secret with the server's RSA public key. Anyone who later stole that private key could decrypt every recorded session. TLS 1.2 also offered ECDHE suites, but they were optional. TLS 1.3 made ephemeral key exchange mandatory, giving forward secrecy: the private key signs, it never decrypts, so stealing it later does not expose past traffic.

TLS 1.2 TLS 1.3
Full handshake 2 round trips 1 round trip
Key exchange RSA or (EC)DHE (EC)DHE only
Cipher suites many, including CBC and RC4-era legacy five AEAD suites
Certificate visible to observers yes no, encrypted
Resumption session IDs, session tickets PSK tickets, optional 0-RTT

TLS 1.0 and 1.1 are deprecated; modern servers should offer 1.2 and 1.3 only.

Inspecting a Handshake with openssl#

openssl s_client -connect example.com:443 -servername example.com -alpn h2 </dev/null

Lines to look for in the output:

Certificate chain
 0 s:CN=example.com
   i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
   ...
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
ALPN protocol: h2
Verify return code: 0 (ok)

s: is each certificate's subject and i: its issuer; each issuer should be the next certificate's subject. Verify return code: 0 (ok) means the chain validated. Always pass -servername; without SNI many servers return a default certificate and you debug the wrong thing. To read expiry dates and names:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

Add -tls1_2 or -tls1_3 to test whether a specific version is accepted.

Inspecting a Handshake from Python#

import socket, ssl

host = "example.com"
context = ssl.create_default_context()          # system CAs, hostname checking on
context.set_alpn_protocols(["h2", "http/1.1"])  # offer HTTP/2, fall back to 1.1

with socket.create_connection((host, 443), timeout=5) as raw:
    with context.wrap_socket(raw, server_hostname=host) as tls:   # sets SNI and the name to verify
        print("version:", tls.version())
        print("cipher: ", tls.cipher())
        print("alpn:   ", tls.selected_alpn_protocol())
        cert = tls.getpeercert()
        print("issuer: ", dict(x[0] for x in cert["issuer"]))
        print("expires:", cert["notAfter"])
        print("names:  ", [v for k, v in cert["subjectAltName"]][:4])
version: TLSv1.3
cipher:  ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
alpn:    h2
issuer:  {'countryName': 'US', 'organizationName': 'SSL Corporation', 'commonName': 'Cloudflare TLS Issuing ECC CA 3'}
expires: Dec 25 22:56:35 2026 GMT
names:   ['example.com', '*.example.com']

The important argument is server_hostname: it sets SNI and tells Python which name the certificate must match. create_default_context() enables certificate and hostname verification; code that builds a bare ssl.SSLContext() or passes verify=False to requests silently disables authentication and makes the connection trivially interceptable. A failing validation raises a clear exception you should surface, not suppress:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired

Troubleshooting Handshake Failures#

Error Cause Fix
certificate has expired / not yet valid expired cert, or wrong client clock renew (automate with ACME); check date
Hostname mismatch / ERR_CERT_COMMON_NAME_INVALID name not in SAN list; wrong SNI reissue with the right names; check which vhost answered
unable to get local issuer certificate server not sending the intermediate, or private CA not trusted serve the full chain (fullchain.pem); add the private CA to the client trust store
self-signed certificate dev cert or TLS-intercepting proxy trust the proxy's CA deliberately, never disable verification
handshake failure / protocol version alert no common version or cipher enable TLS 1.2+ on both ends; update old clients
Works in browser, fails in Python browsers can fetch missing intermediates; Python uses its own CA bundle fix the server chain; check certifi or REQUESTS_CA_BUNDLE

The "works in the browser" row catches many people: a browser may have the intermediate cached from another site, so an incomplete chain looks fine in Chrome but fails in curl, Python and mobile apps.

To decrypt your own traffic in Wireshark for debugging, set the SSLKEYLOGFILE environment variable before starting a browser or curl build that supports it (Python exposes the same through SSLContext.keylog_filename), then point Wireshark's TLS settings at that file. Do this only on your own machine: the file contains secrets for every logged session.

Session Resumption and 0-RTT#

A full handshake costs CPU and a round trip, so TLS supports resumption. In TLS 1.2, servers issue session IDs (server keeps state) or session tickets (encrypted state held by the client). TLS 1.3 unifies this as pre-shared key (PSK) resumption from a ticket sent after the handshake.

TLS 1.3 also allows 0-RTT early data: on resumption the client sends its first request together with the ClientHello. It is fast, but early data can be replayed by an attacker who recorded it, and it lacks forward secrecy for that first flight. Servers and CDNs should accept 0-RTT only for idempotent requests (safe GETs), never for logins, payments or other state changes.

What Observers Still See#

TLS hides content, not metadata. An on-path observer sees the IP addresses, the timing and size of records, and, in a standard ClientHello, the SNI hostname in cleartext. Encrypted Client Hello (ECH) encrypts the SNI and other sensitive extensions using a key published in DNS, and is supported by some browsers and CDNs, but it is not yet universal.

The ClientHello is also a fingerprint. Its list and order of cipher suites, extensions and curves differ between Chrome, Firefox, curl and Python's ssl module, which is the basis of JA3/JA4 fingerprinting: servers can identify the client software before any HTTP header arrives. See TLS Fingerprinting for how this affects automated clients, and TLS/SSL for server configuration hardening.

Mutual TLS#

In ordinary TLS only the server presents a certificate. With mutual TLS (mTLS) the server also sends a CertificateRequest, and the client authenticates with its own certificate. Service meshes use mTLS between microservices so every connection is authenticated in both directions, and it is common for machine-to-machine APIs and zero-trust network access.

Practice#

Using openssl s_client, compare the certificate chain, negotiated version and ALPN result for two HTTPS sites you operate or use daily. Then run the Python example against a host whose certificate you know is broken on a test server you control (or a deliberately misconfigured test endpoint such as expired.badssl.com) and match the exception to a row in the troubleshooting table.