Almost every connection a modern service makes is wrapped in TLS: browsers to load balancers, services to databases, agents to model APIs. The protocol is still often called SSL, after the Netscape protocol it replaced, but every SSL version and TLS 1.0 and 1.1 are deprecated; what runs today is TLS 1.2 and, increasingly, TLS 1.3, specified in RFC 8446. When TLS fails, it fails with messages like certificate verify failed or handshake failure, and diagnosing them needs a working model of what each side is doing.

This article builds that model. It covers what TLS guarantees, the record layer that carries every byte, the TLS 1.3 handshake message by message, the key schedule, how a client decides to trust a certificate, resumption and 0-RTT, the differences from TLS 1.2 that still matter in mixed fleets, post-quantum key exchange, and how to debug and operate all of it. Mutual TLS, where the client also presents a certificate, is covered in mTLS.

Advertisement

What TLS protects, and what it does not

TLS gives a byte stream three properties. Confidentiality: an observer on the path cannot read the data. Integrity: any modification is detected and the connection is torn down. Authentication: the client knows it is talking to a holder of the private key for a certificate that names the host it asked for. The client is usually not authenticated by TLS at all; that is the application's job unless mTLS is used.

It leaves things visible. IP addresses and ports are in every packet, the server name travels in clear in the SNI extension unless Encrypted Client Hello is deployed on both ends, and record sizes and timing leak the shape of traffic. Nor does TLS help a compromised endpoint: data is plaintext again once it leaves the TLS library.

Two layers: handshake and records

TLS is built from two layers. The handshake protocol negotiates the version, algorithms and keys, and authenticates the server. The record protocol carries everything, handshake messages and application data alike, in records of at most 214 bytes (16 KiB) of plaintext each.

Once keys are established, each record is sealed with an AEAD cipher, AES-GCM or ChaCha20-Poly1305 in practice, which encrypts and authenticates in one operation. TLS 1.3 never sends a nonce on the wire. Each side keeps a 64-bit record sequence number per direction, and the per-record nonce is the static IV derived from the key schedule XORed with that sequence number. A replayed, dropped or reordered record therefore fails authentication, because the receiver decrypts with the wrong nonce. This is why TLS runs over a reliable, ordered transport such as TCP; QUIC carries the same handshake but protects packets differently because UDP can reorder them.

TLS 1.3 also hides the real record type inside the encrypted payload, allows padding to blur lengths, and fixes the outer version field at the TLS 1.2 value for middlebox compatibility.

Advertisement

The TLS 1.3 handshake, message by message

The client opens with a ClientHello: a 32-byte random value, the versions it supports (in the supported_versions extension), its cipher suites, the signature algorithms it accepts, the server name (SNI), application protocols it wants (ALPN, for example h2), and, crucially, one or more key shares: ephemeral Diffie-Hellman public keys for the groups it guesses the server will pick, typically X25519.

The server answers with a ServerHello carrying its own key share for one of those groups. Both sides can now compute the shared secret and derive handshake keys, so everything after ServerHello is encrypted. The server sends EncryptedExtensions, its Certificate chain, a CertificateVerify signature over the hash of every message so far, which proves possession of the private key and binds it to this handshake, and a Finished message, a MAC over the transcript. The client verifies the chain and both, sends its own Finished, and can send application data immediately.

That is one round trip, against two for a full TLS 1.2 handshake. If the client guessed a group the server does not support, the server replies with a HelloRetryRequest naming the group it wants, and the handshake costs an extra round trip.

TLS 1.3 full handshake: one round trip before the client can send application dataClientServerClientHellorandom, supported_versions, cipher suites, key_share, SNI, ALPN, sig algsServerHellochosen suite, server key_share (both sides now derive handshake keys)encrypted with handshake traffic keysEncryptedExtensionsALPN result, other extensionsCertificate + CertificateVerifychain, then a signature over the transcriptFinishedMAC over the whole transcriptFinishedclient MAC; may be followed at once by application dataapplication data, encrypted with application traffic keyslater: NewSessionTicket from server enables resumptionAnything after ServerHello is encrypted, including the server certificate
The TLS 1.3 handshake. Key shares travel in the first two messages, so certificates and extensions are already encrypted.
TLS 1.3 cipher suiteAEADHash for the key schedule
TLS_AES_128_GCM_SHA256AES-128-GCMSHA-256
TLS_AES_256_GCM_SHA384AES-256-GCMSHA-384
TLS_CHACHA20_POLY1305_SHA256ChaCha20-Poly1305SHA-256

In TLS 1.3 a cipher suite names only the record cipher and the hash; key exchange and signature algorithms are negotiated separately. RFC 8446 also defines two AES-CCM suites for constrained devices.

The key schedule

Every key in a TLS 1.3 connection comes from one HKDF chain. HKDF-Extract mixes new secret input into the chain; Derive-Secret expands it into a labelled secret bound to a hash of the handshake transcript so far. Binding to the transcript is what makes a tampered handshake produce different keys on each side, so the Finished check fails.

# Labels as in RFC 8446 section 7.1; HKDF-Expand-Label prefixes each with "tls13 ".
early      = HKDF_Extract(salt=zeros, ikm=psk or zeros)
hs_secret  = HKDF_Extract(salt=Derive_Secret(early, "derived", b""), ikm=ecdhe_shared_secret)
c_hs       = Derive_Secret(hs_secret, "c hs traffic", ClientHello..ServerHello)
s_hs       = Derive_Secret(hs_secret, "s hs traffic", ClientHello..ServerHello)
master     = HKDF_Extract(salt=Derive_Secret(hs_secret, "derived", b""), ikm=zeros)
c_app      = Derive_Secret(master, "c ap traffic", ClientHello..server Finished)
s_app      = Derive_Secret(master, "s ap traffic", ClientHello..server Finished)

def traffic_keys(secret, key_len):
    key = HKDF_Expand_Label(secret, "key", b"", key_len)
    iv  = HKDF_Expand_Label(secret, "iv",  b"", 12)
    return key, iv                      # nonce for record n = iv XOR n (left-padded)

Two properties fall out. Because the ECDHE secret is ephemeral and discarded, recording traffic and later stealing the server's certificate key does not decrypt it: forward secrecy is mandatory in TLS 1.3. And because handshake and application keys are separate, the certificate exchange is protected even though the server has not yet been authenticated. Tools such as Wireshark can decrypt captures if the endpoint logs these secrets via the SSLKEYLOGFILE convention, which is invaluable in a lab and must never be enabled in production.

How a client decides to trust a certificate

The server sends its leaf certificate followed by intermediates. The client builds a path from the leaf to a root in its trust store and checks, for every link, that the signature verifies, that the issuer is permitted to act as a CA, and that the current time is within the validity period. For the leaf, it checks that a Subject Alternative Name matches the host it connected to; browsers ignore the Common Name. It checks that the extended key usage allows server authentication, and applies whatever revocation checking the client implements.

Most real failures are configuration, not cryptography. The server must send intermediates, because clients are not required to fetch missing ones. Validity is shrinking: under CA/Browser Forum ballot SC-081, the maximum lifetime of publicly trusted TLS certificates fell to 200 days for certificates issued from 15 March 2026, falls to 100 days from 15 March 2027 and to 47 days from 15 March 2029. Manual renewal does not survive that schedule; issuance and deployment must be automated, typically with ACME. Pinning a specific key adds another way to fail and is discussed in certificate pinning.

Resumption and 0-RTT

After the handshake, the server may send NewSessionTicket messages. Each carries an opaque ticket and lets the client derive a pre-shared key. On the next connection the client offers the PSK in its ClientHello and the server can skip certificate verification. In the recommended mode it still performs a fresh ECDHE exchange alongside the PSK, keeping forward secrecy.

A resuming client can also send 0-RTT early data in its first flight, encrypted under a key derived from the PSK alone. It saves a round trip but has two weaknesses: it lacks forward secrecy with respect to the ticket key, and an attacker can replay it, since the server has not yet contributed any fresh randomness. Only accept idempotent requests as early data, such as a GET with no side effects. HTTP has a status code, 425 Too Early, for a server that wants the client to retry after the handshake.

Ticket encryption keys are the hidden secret of a TLS deployment. Anyone holding them can decrypt resumed sessions, so they must be rotated frequently and shared across a load-balanced pool only through a secure channel. A fleet that never rotates its ticket keys has quietly given up much of forward secrecy.

What changed from TLS 1.2

Mixed fleets still run TLS 1.2, which remains acceptable when configured tightly. TLS 1.2 allowed static RSA key exchange, where the client encrypts the premaster secret to the server's certificate key, so a stolen key decrypts all recorded traffic; TLS 1.3 removed it. TLS 1.2 also allowed CBC cipher suites with MAC-then-encrypt, the source of padding-oracle attacks; TLS 1.3 allows only AEAD. Renegotiation, compression and custom DH groups are gone. A safe TLS 1.2 configuration therefore offers only ECDHE key exchange with AEAD ciphers.

Post-quantum key exchange

An attacker who records traffic today could decrypt it later if a large quantum computer breaks elliptic-curve Diffie-Hellman. This harvest-now-decrypt-later threat applies to key exchange now, while signatures only need replacing before such a machine exists. The deployed answer is a hybrid group, X25519MLKEM768, which combines an X25519 exchange with ML-KEM-768, the lattice-based key encapsulation standardised by NIST in FIPS 203. The session is safe if either component holds.

Current Chrome, Edge and Firefox negotiate it by default, and OpenSSL 3.5 ships ML-KEM, so servers built against it can offer the group. The operational cost is size: an ML-KEM-768 key share is over a kilobyte, pushing the ClientHello past a single small packet. Middleboxes that assume a ClientHello fits in one segment, or that do not recognise the group, have broken connections. When rolling it out, watch handshake failure rates by client and network path.

Inspecting a connection from code and the command line

import socket, ssl

host = "example.com"
ctx = ssl.create_default_context()          # CERT_REQUIRED, hostname checking, system roots
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_alpn_protocols(["h2", "http/1.1"])

with socket.create_connection((host, 443), timeout=5) as raw:
    with ctx.wrap_socket(raw, server_hostname=host) as tls:   # sets SNI and the name to verify
        print(tls.version(), tls.cipher(), tls.selected_alpn_protocol())
        cert = tls.getpeercert()
        print(cert["subjectAltName"], cert["notAfter"])
# Full chain as served, with SNI, forcing TLS 1.3
openssl s_client -connect example.com:443 -servername example.com -tls1_3 -showcerts </dev/null

# Expiry and names of a certificate file
openssl x509 -in leaf.pem -noout -subject -dates -ext subjectAltName

# Does the server accept the hybrid group? (needs OpenSSL 3.5 or later)
openssl s_client -connect example.com:443 -servername example.com -groups X25519MLKEM768 </dev/null

Never fix a verification error by setting verify_mode = CERT_NONE or check_hostname = False. It removes authentication entirely, and an attacker in the path can then present any certificate.

Worked example: it works in the browser but not in the service

A team deploys a new certificate. Browsers load the site, but a Python job fails with CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate. Running openssl s_client -showcerts shows the server sending only the leaf, depth 0, with no intermediate. Browsers succeed because they cache intermediates from earlier sites or fetch them using the certificate's authority information; the Python client does neither. The fix is on the server: deploy the full chain file, leaf first then intermediates, not the leaf alone. Re-running s_client shows depth 0 and 1 and Verify return code: 0 (ok). The lesson: test new certificates with a strict non-browser client before declaring the rollout done.

Failure modes

  • Expired certificate. Outage at a precise minute. Monitor days-to-expiry for every endpoint, including internal ones, and alert well ahead.
  • Missing intermediate. Works in browsers, fails in services. Serve the full chain.
  • No SNI. An old client or a raw IP connection gets the default certificate, which fails hostname checks.
  • Clock skew. A device with a wrong clock rejects valid certificates as not yet valid or expired. Keep clocks synchronised.
  • Middlebox intolerance. Large ClientHellos or unknown groups break through some firewalls and proxies; roll out new groups gradually.
  • Unrotated ticket keys. A long-lived ticket key decrypts every resumed session it protected.
  • Plaintext behind the load balancer. Terminating TLS at the edge and forwarding plaintext inside the network leaves the internal hop exposed; re-encrypt or use mTLS internally.

Operating TLS at scale

Treat every termination point, whether CDN, load balancer, sidecar or application, as a place that holds keys. Prefer ECDSA P-256 certificates, smaller and cheaper to sign with than RSA-2048, keeping RSA only for old clients. Export handshake failures by alert type, negotiated version and group, and resumption rate. The same handshake runs inside QUIC; see QUIC architecture.

DecisionOption AOption B
Minimum versionTLS 1.3 only: smaller attack surfaceTLS 1.2 and 1.3: reaches older clients
Certificate keyECDSA P-256: small, fastRSA-2048: widest compatibility
0-RTTEnabled for idempotent requests: lower latencyDisabled: no replay risk
TerminationAt the edge only: simpleRe-encrypt to backends: protects internal hops

What to do next

  1. Inventory every TLS endpoint you own and record its certificate expiry, chain and termination point.
  2. Scan each with openssl s_client and confirm a full chain, TLS 1.2 minimum and AEAD-only ciphers.
  3. Automate certificate issuance and deployment before the 100-day limit takes effect in March 2027.
  4. Rotate session ticket keys on a schedule and restrict 0-RTT to idempotent requests.
  5. Offer X25519MLKEM768 where your TLS library supports it and monitor handshake failures as you do.
  6. Add expiry, handshake failure and negotiated-group metrics to your dashboards.
Key takeaway: TLS wraps a byte stream in confidentiality, integrity and server authentication. TLS 1.3 does it in one round trip: key shares in the first two messages, an HKDF key schedule bound to the transcript, mandatory forward secrecy and AEAD-only records. Most outages are operational: expired certificates, missing intermediates, absent SNI and unrotated ticket keys. Automate certificates, test with strict clients, and roll out hybrid post-quantum key exchange while watching failures.