Networking & APIs· 3 of 13
TLS and Encryption in Transit
Anyone can send your certificate. Only one signature proves it is you, and it says nothing about sizes, replays or what happens after the load balancer.
The question
Section titled “The question”Your bank’s certificate is public. Anyone on the café Wi-Fi can download it and send it to you, byte for byte, when you try to connect. So what exactly stops them from being the bank? And if they cannot be the bank, how can they still tell which page of a clinic’s website you read, or copy your encrypted payment request and have the bank’s servers run it twice, without breaking a single cipher?
“TLS encrypts, certificates prove identity” is true and explains none of that. This page is about what the handshake really proves, what it leaves in the clear, which keys can undo it years later, why revocation barely works, and what breaks as certificate lifetimes fall to 47 days.
What TLS promises, word by word
Section titled “What TLS promises, word by word”TLS (Transport Layer Security) protects one byte stream between two endpoints. It is built from two kinds of cryptography, and the rest of the page leans on the difference:
- Symmetric (shared-key) encryption uses one key both to lock and to unlock. AES is the common one, and every modern CPU has instructions for it, so it is fast. But both sides must already hold the same key, and two strangers on the internet do not.
- Public-key cryptography gives each party a key pair: a private key it keeps secret and a public key it gives to anyone. What one key does, only the other can undo or check. Anyone can encrypt to the bank’s public key and only the bank can decrypt; only the bank can sign with its private key, and anyone can check the signature with the public one. No shared secret is needed, but the operations are far slower and work only on small inputs.
So TLS uses public-key operations only during the handshake, to agree on a fresh symmetric key and
to prove who is on the other end, and then encrypts the data with that symmetric key. A
certificate ties a public key to a name: a small public document that says “this public key
belongs to bank.example.in”, signed by a certificate authority (CA). The CA’s own certificate
is signed in turn, up to a root CA whose certificate ships inside your operating system or
browser, in its trust store.
Stated precisely, after a successful handshake the client knows this:
The bytes I receive were sent by someone holding the private key of a certificate that names the host I asked for and chains to a root in my trust store, and nobody has changed, reordered, dropped or replayed them within this connection.
Every qualifier in that sentence is a place where real systems break, and each has a section below:
- “Someone holding the private key”: the certificate is public; the key is the secret. A stolen key makes the thief the bank until the certificate expires, because revocation does not really work.
- “The host I asked for”: only if the client checks the name.
verify=Falsedrops this, and with it everything else. - “A root in my trust store”: about 120 roots, any of which may sign for any name, plus any root your employer installed on your laptop.
- “Within this connection”: a copy of the first flight, sent on a new connection, is outside the promise. That is the 0-RTT replay.
- What is not in the sentence at all: who you talk to, when and how much. Sizes and timing are visible. So is the site name, unless Encrypted Client Hello is in use.
Once the handshake has produced a symmetric key, the data travels in records of up to 16 KB. Encryption alone hides bytes but does not stop someone flipping them: a changed ciphertext decrypts to changed plaintext, and the receiver cannot tell. So each record also carries a tag, or MAC (message authentication code): a 16-byte value computed from the key and the encrypted bytes. Change one bit and the tag no longer matches, and without the key nobody can compute one that does. A cipher that encrypts and tags in one operation is an AEAD (authenticated encryption with associated data, the associated data being the record header, which is checked but left readable). TLS 1.3 allows only AEAD ciphers; in practice AES-GCM or ChaCha20-Poly1305.
The last ingredient is a nonce, a number used only once per key and mixed into each record so that two identical records encrypt differently. TLS takes it from a record counter that both sides keep and never send. Swap records 1 and 2 on the wire, and the receiver opens the first to arrive with nonce 1, but it was sealed with nonce 2: the tag fails and the connection is torn down. Dropped and repeated records fail the same way. The record layer is the part of TLS that rarely goes wrong. The handshake that creates its keys is where the interesting failures are.
The obvious fix, and why it breaks
Section titled “The obvious fix, and why it breaks”Two strangers can agree on a symmetric key in public with Diffie-Hellman key exchange. It rests
on one piece of arithmetic. Write x mod p for the remainder after dividing x by p: 27 mod 23 = 4.
Raising a number to a power and then taking the remainder is fast, even for numbers hundreds of
digits long. Going back, from the remainder to the exponent, has no known shortcut at those sizes.
Both sides agree in public on a prime p and a base g. Each picks a private random exponent and sends g to that power mod p, its key share. Each then raises the other’s share to its own exponent. Both arrive at the same number, because (ga)b = (gb)a, and that number is the shared secret. With numbers small enough to check:
That defeats anyone who only listens. It does nothing against someone who answers. Here is the attack the hotspot owner, Mallory, runs:
- Step 1 of 7your share
You send your key share towards the bank. Every packet passes through the hotspot Mallory runs, so she keeps it.
- Step 2 of 7her share 1
She sends the bank a share she generated herself. A share is a large random-looking number; nothing in it says who made it.
- Step 3 of 7bank's share
The bank replies with its share, believing it is talking to you.
- Step 4 of 7her share 2
Mallory keeps the bank's share and sends you a second share of her own.
- Step 5 of 7two keys
You share a key with Mallory; the bank shares a different key with Mallory. Neither of you shares a key with the other.
- Step 6 of 7pay ₹900
Your encrypted "pay ₹100" arrives at Mallory. She decrypts it, rewrites it, encrypts it again with the bank-side key and computes a fresh, valid integrity tag.
- Step 7 of 7The whole flow
Every message was encrypted and every tag was valid. Diffie-Hellman kept the key secret from listeners, but nothing told either side who sent the key shares.
The mitm_dh example runs this with real 2048-bit Diffie-Hellman numbers and an encrypt-then-MAC
channel:
Key fingerprints (first 8 bytes of each key): Alice: 5c0e3a752bcbe3c0 Mallory's copy: 5c0e3a752bcbe3c0 Bank: 6619d571a8c95184 Mallory's copy: 6619d571a8c95184 Alice and the bank hold different keys, and Mallory holds both.
Alice sends an encrypted, integrity-protected payment. Mallory reads: {"to": "ravi@okbank", "amount_inr": 100} Bank receives: {"to": "mallory@ok", "amount_inr": 900} (tag valid)Alice and the bank hold different keys. Comparing fingerprints over a channel Mallory cannot touch would expose her, which is what SSH asks you to do on first connection. TLS needs the same check to happen automatically, for a billion clients and servers that have never met.
The core idea
Section titled “The core idea”The fix needs two more building blocks.
A hash function turns any input into a short, fixed-size fingerprint. SHA-256, the one TLS
usually uses, gives 32 bytes. The same input always gives the same output, changing one character
changes the output completely, and nobody can construct a second input with the same hash. The
SHA-256 of pay ₹100 starts 14695baf; that of pay ₹900 starts 044dcdf1. A hash has no key,
so anyone can compute one, which is what lets two sides compare.
A digital signature is a value that only the private key can produce for a given hash, and that anyone holding the public key can check. Here it is with toy RSA numbers: the public key is n = 33 and e = 3, the private key is d = 7. To sign a message whose hash is 4, the signer computes 47 mod 33 = 16 and sends 16 with the message. The verifier computes 163 mod 33 = 4, hashes the message it received itself, and accepts only if both give 4. If Mallory changes the message so its hash becomes 5, the signature still opens to 4 and the check fails. The signature that opens to 5 is 14, and computing it needs d. Real signatures use 2048-bit RSA or elliptic-curve keys, where d cannot be worked out from the public key.
The server signs the conversation. After sending its certificate, the server takes a hash of every handshake message so far, the transcript, with both random values and both key shares included, and signs that hash with the private key that matches the certificate. This signature is the CertificateVerify message. Both sides then send Finished, a MAC over the same transcript using keys derived from the shared secret.
Now replay Mallory’s attack. If she forwards your key share to the bank unchanged, the bank’s signature is valid, but the key is shared between you and the bank, and she cannot read anything. If she swaps in her own share, the bank signs a transcript containing her share; you check the signature against a transcript containing yours, and the hashes differ. To fix that, she would have to sign your transcript herself, with the bank’s private key. Sending the bank’s certificate costs her nothing. Producing that signature is the one thing she cannot do.
That is the whole of server authentication in TLS 1.3: a certificate that the client’s trust store vouches for, plus a fresh signature from its key over this particular handshake. Everything else on this page is about what that sentence does not cover.
How it works, piece by piece
Section titled “How it works, piece by piece”What the handshake proves, message by message
Section titled “What the handshake proves, message by message”Two terms first. A group is a named, standard choice of Diffie-Hellman parameters, the real-size version of p = 23 and g = 5. X25519 is the common one, an elliptic-curve form of the same idea with 32-byte shares. And TLS never uses the shared secret directly as a key. It feeds the secret and the transcript hash through a hash-based function (HKDF) that derives a separate key for each direction and each stage, so changing any input changes every key.
- Step 1 of 9ClientHello
A fresh 32-byte random value, supported versions and ciphers, the site name (SNI), the application protocols (ALPN), and key shares already computed for the groups it guesses the server supports, such as X25519MLKEM768 and X25519.
- Step 2 of 9ServerHello
Its own random value and key share for one chosen group. Both sides now compute handshake keys; everything after this message is encrypted.
- Step 3 of 9EncryptedExt
The rest of the server's choices, such as h2 or http/1.1. TLS 1.2 sent these in the clear.
- Step 4 of 9Certificate
The chain: the server's certificate and its intermediates, without the root. Encrypted in TLS 1.3, readable by anyone in TLS 1.2.
- Step 5 of 9CertVerify
A signature over the transcript hash with the certificate's private key. This is the step a man in the middle cannot fake.
- Step 6 of 9Finished
A MAC over the transcript with keys only the two real endpoints can derive.
- Step 7 of 9verify all
The client builds the chain to a trusted root, checks the name, the dates, the signature and the Finished MAC. Any failure ends the connection here.
- Step 8 of 9Finished + GET
Its own Finished and, in the same flight, the first encrypted request. One round trip after the ClientHello.
- Step 9 of 9The whole flow
The key shares give a fresh secret; the certificate says which key belongs to the name; CertificateVerify proves that key's holder took part in this exact handshake; Finished proves nobody altered any message.
Each way of attacking the handshake meets one specific defence:
| Attack | What stops it |
|---|---|
| Swap a key share | CertificateVerify covers both shares; the signature fails |
| Replay a recorded handshake from yesterday | The client’s random value is new, so yesterday’s signature covers a different transcript |
| Edit the ClientHello to remove strong ciphers or force TLS 1.2 | Finished covers the ClientHello; a TLS 1.3 server pushed to 1.2 also writes a fixed marker into the last 8 bytes of its random value, which TLS 1.3 clients check |
| Present a certificate for the name from a CA you made | Chain building fails: your root is not in the client’s store |
| Present a certificate from a CA the client does trust | Nothing in TLS. Certificate Transparency makes it visible afterwards |
The last row is the honest limit. The client authenticates “a key some trusted CA vouched for”, not
“the bank”. In 2011 attackers who broke into the Dutch CA DigiNotar issued a *.google.com
certificate that intercepted Gmail for around 300,000 IP addresses, almost all in Iran
(Fox-IT interim report).
Since then, Certificate Transparency (RFC 6962) requires
CAs to put every certificate into public append-only logs, and Chrome refuses certificates without
proof of logging from at least two log operators
(Chrome CT policy).
Mis-issuance still works for a while, but it can no longer stay secret: search
crt.sh for your own domains. A CAA record in your DNS zone narrows the field
before issuance: it names the CAs allowed to issue for your domain, and every public CA must check
it (RFC 8659). It stops an honest CA from being tricked
into issuing for you; it cannot stop a compromised one that ignores it.
A corporate inspection proxy uses the same limit on purpose. It issues certificates on the fly from a company root installed on managed laptops, and from the browser’s point of view every check passes. TLS worked perfectly; the endpoint you authenticated was the proxy.
Notice also what the server learned about the client: nothing. In ordinary TLS the client is anonymous until the application asks for a password or token, and that token can be replayed from anywhere. Mutual TLS, later on this page, fixes that between services.
Forward secrecy, and the keys that quietly undo it
Section titled “Forward secrecy, and the keys that quietly undo it”Forward secrecy means that stealing a server’s long-term private key next year does not decrypt traffic recorded today. It comes from the word ephemeral: both sides pick fresh Diffie-Hellman secrets per handshake and delete them afterwards, so the secret that protected a recording no longer exists anywhere. In the toy exchange, once both sides forget 6 and 15, the only way from a recording of 8 and 19 back to 2 is the hard reverse step, for the bank as much as for anyone. The certificate’s key only signs; it never encrypts the session secret. TLS 1.2’s older RSA key exchange did the opposite: the client encrypted the session secret to the server’s certificate key, so one stolen key decrypts every recording ever made. TLS 1.3 removed it.
The part teams miss is session resumption. A full handshake costs a key exchange, a signature and a chain check; a client that returns a minute later would rather reuse a secret from its last connection. The server could keep those secrets in a table, but in a cluster the next connection may land on any server. So the server hands the secret to the client, locked: it encrypts it with a symmetric key of its own, the session ticket encryption key (STEK), and sends the result as a session ticket. The client cannot read the ticket; it only stores it. On Monday the client receives ticket T. On Tuesday it puts T in its ClientHello, server 7 of 10 decrypts T with the same STEK, recovers the secret, and both sides continue from it without the certificate or signature.
- In TLS 1.2, the ticket contains the connection’s master secret, the one value from which every key of that connection was derived. Anyone who records traffic and later steals the STEK can decrypt the ticket and then the whole connection, including the original full handshake. Many servers created one STEK at startup and kept it for months, so in practice forward secrecy lasted as long as the process (Langley, 2013).
- In TLS 1.3, the ticket holds a resumption secret derived one way from the old session, and
resumption normally mixes in a fresh Diffie-Hellman exchange (the
psk_dhe_kemode browsers use). A stolen STEK then decrypts neither the old connection nor the resumed one. It does decrypt any 0-RTT early data, which is encrypted with the ticket’s key alone.
So the STEK is a long-term key in disguise. Rotate it every few hours to a day, distribute it to the cluster as a secret, and keep old ones only as long as tickets live (at most 7 days in TLS 1.3).
Harvest now, decrypt later is the same problem at a larger scale. A large enough quantum computer could work back from an X25519 share to its secret, so traffic recorded today could be opened then. The fix is a hybrid key exchange, X25519MLKEM768, which runs X25519 and ML-KEM side by side. ML-KEM agrees on a secret using different mathematics (lattice problems) that no known quantum algorithm breaks. Both secrets go into key derivation, so an attacker must break both, and the session is safe if either holds (RFC 10024, August 2026). The cost is size: the client’s share grows from 32 to 1,216 bytes, so the ClientHello no longer fits in one packet, and middleboxes that assumed it would broke when browsers turned it on (tldr.fail). As of February 2026, over 60% of clients reaching Cloudflare offered it, but only about 10% of origin servers supported it (Cloudflare).
What an observer still sees
Section titled “What an observer still sees”Encryption hides content, not shape. Here is what someone on the path learns from one visit, with no keys at all:
The SNI (server name indication) is in the ClientHello because the server needs it to pick a
certificate before any keys exist. Encrypted Client Hello (ECH,
RFC 9849, March 2026) uses public-key encryption
to hide it. The hosting provider publishes an ECH public key in its DNS records. The client
encrypts the real ClientHello, with the real name, to that key and wraps it inside an outer
ClientHello whose SNI names only the provider: the observer reads ech.provider.example, and only
the provider’s private key opens the inner clinic.example.in. It hides the site only when many sites share that provider’s addresses. A site on its own IP address
gains nothing: the address already names it.
Sizes leak more than names. Look at the one field TLS cannot hide:
The observer example puts a relay with no keys between a client and a server, the position of
every Wi-Fi router, and reads only the plaintext parts:
# runnable version: examples/tls/python/observer.pydef records(stream: bytes) -> list[tuple[int, int]]: """Split raw TLS bytes into (content type, length) using only the 5-byte plain headers.""" out, i = [], 0 while i + 5 <= len(stream): length = int.from_bytes(stream[i + 3 : i + 5], "big") out.append((stream[i], length)) i += 5 + length return out
def server_name(client_hello: bytes) -> str: """Walk the plaintext ClientHello to the server_name extension (type 0).""" i = 5 + 4 + 2 + 32 # record header, handshake header, version, random i += 1 + client_hello[i] # session id i += 2 + int.from_bytes(client_hello[i : i + 2], "big") # cipher suites i += 1 + client_hello[i] # compression methods end = i + 2 + int.from_bytes(client_hello[i : i + 2], "big") i += 2 while i < end: kind, size = int.from_bytes(client_hello[i : i + 2], "big"), int.from_bytes(client_hello[i + 2 : i + 4], "big") if kind == 0: # list length (2), name type (1), name length (2), name return client_hello[i + 9 : i + 9 + int.from_bytes(client_hello[i + 7 : i + 9], "big")].decode() i += 4 + size return "(none)"// runnable version: examples/tls/js/observer.mjs/** Split raw TLS bytes into [content type, length] using only the 5-byte plain headers. */function records(stream) { const out = []; for (let i = 0; i + 5 <= stream.length; i += 5 + stream.readUInt16BE(i + 3)) { out.push([stream[i], stream.readUInt16BE(i + 3)]); } return out;}
/** Walk the plaintext ClientHello to the server_name extension (type 0). */function serverName(hello) { let i = 5 + 4 + 2 + 32; // record header, handshake header, version, random i += 1 + hello[i]; // session id i += 2 + hello.readUInt16BE(i); // cipher suites i += 1 + hello[i]; // compression methods const end = i + 2 + hello.readUInt16BE(i); for (i += 2; i < end; i += 4 + hello.readUInt16BE(i + 2)) { if (hello.readUInt16BE(i) === 0) { // list length (2), name type (1), name length (2), name return hello.subarray(i + 9, i + 9 + hello.readUInt16BE(i + 7)).toString(); } } return '(none)';}TLS 1.3 GET /dental server name (SNI), in the clear: clinic.example.in CA name readable on the wire: False request record: 41 bytes response record: 1,217 bytes, for a 1,200-byte pageTLS 1.3 GET /hiv-test server name (SNI), in the clear: clinic.example.in CA name readable on the wire: False request record: 43 bytes response record: 5,417 bytes, for a 5,400-byte pageTLS 1.2 GET /dental server name (SNI), in the clear: clinic.example.in CA name readable on the wire: True request record: 48 bytes response record: 1,224 bytes, for a 1,200-byte pageTLS 1.2 GET /hiv-test server name (SNI), in the clear: clinic.example.in CA name readable on the wire: True request record: 50 bytes response record: 5,424 bytes, for a 5,400-byte pageRead the numbers. The response is exactly the page plus 17 bytes in TLS 1.3 and plus 24 in TLS 1.2
(an 8-byte explicit nonce and the tag). Even the request gives away its path length: /hiv-test
is two characters longer than /dental, and its record is two bytes longer. TLS 1.2 also showed the
observer the whole certificate chain; TLS 1.3 encrypts it.
Real pages are many requests with sizes that vary a little per visit, and that turns out not to help much. In 2014, researchers identified individual pages within ten large HTTPS sites, including health and finance sites, with about 89% accuracy from traffic shape alone; their best defence cut it to 27% for 9% more traffic (Miller et al., PETS 2014). Here is the mechanism on a toy site with twelve pages. The observer measured each page’s typical size in advance and, for every visit, names the page whose size is closest to what it saw:
With the defaults the observer names the page in about 82% of visits, against 8% for a blind guess. Padding every response up to a multiple of 16 KB drops it to 27%, at the cost of 23% more bytes; 32 KB buckets get to 21% for 52% more. Padding works by making pages share a size, so it only helps when the buckets are wider than the gaps between real page sizes, and you pay for it in bandwidth on every response. TLS 1.3 records can carry padding, but no common server adds it for you: it is an application decision.
0-RTT: a request that can run twice
Section titled “0-RTT: a request that can run twice”A full handshake costs one round trip (one RTT) before the first request can leave. On a resumed connection TLS 1.3 can make that zero, hence 0-RTT: the client already holds a secret from its ticket, so it sends its first request inside the very first flight, as early data, encrypted with a key derived from that secret. A user 200 ms away gets the answer 200 ms sooner. The price follows from the table above: a normal handshake is protected against replay because the server’s fresh random value enters the keys. Early data is sent before the server has said anything, so its keys depend only on the client’s flight. Anyone can copy that flight and send it again on a new connection, and it decrypts correctly every time.
The specification says plainly that TLS gives early data no protection against replay between
connections, and lists what servers can do (RFC 8446, section 8):
use each ticket once, or remember every ClientHello seen recently, plus a freshness check that
rejects a ClientHello whose ticket age is too far from what the server expects, on the order of ten
seconds. Both memories have to live somewhere, and in a cluster that is the whole problem. Watch
an attacker copy every POST /transfer four times and send the copies through the same load
balancer:
With a replay cache inside each server, a copy is caught only if it reaches the server that saw the original. With 8 servers and 4 copies per request, 570 of 800 copies ran in a five-minute run with the default seed: about 71%, close to the 72% you get from counting how many distinct servers five random picks reach. Per-server memory protects one server, and a cluster of one. A shared cache catches every copy, but now every 0-RTT handshake waits on a lookup in a shared store, and when that store is unreachable the only safe choice is to refuse early data. The cheapest complete answer is the third one: do not accept anything in early data that is unsafe to run twice.
That is what production systems do. As of October 2026, Cloudflare’s 0-RTT is off by default,
accepts only GET, HEAD and OPTIONS in early data, and adds an Early-Data: 1 header so the
origin knows
(Cloudflare docs).
An origin that sees that header on a request it cannot replay answers 425 Too Early, and the
client resends after the full handshake (RFC 8470).
Remember also that early data has no forward secrecy against a stolen STEK, from the previous
section.
Chain building, and why “works in my browser” proves nothing
Section titled “Chain building, and why “works in my browser” proves nothing”A certificate is a short structured record in a format called X.509. The fields that matter here:
- Subject Alternative Name (SAN): the names it is valid for, such as
bank.example.inand*.bank.example.in. - Public key: the key whose private half must sign CertificateVerify.
- Issuer: the CA that signed it.
- Validity: not before and not after, for example 1 October to 17 November 2026.
- Serial number: unique per CA, used to revoke it.
- Extensions that limit the key:
CA:TRUEorCA:FALSE(may it sign other certificates?) and the allowed use, such as server authentication. - The issuer’s signature over a hash of all the fields above.
The last field makes the certificate a signed statement: the issuer vouches that this key belongs to these names until that date. Add one name and the hash changes, so the signature fails. Checking that signature needs the issuer’s public key, which sits in the issuer’s own certificate, which is signed by its issuer. A root signs its own certificate, so its signature proves nothing; it is trusted only because it is already in the trust store.
A server sends its own certificate (the leaf) and the intermediate CA certificates that signed it. The client must build a path from the leaf to a root already in its trust store, and then check every link.
For every certificate on the path the client checks the signature against the issuer’s public key, the validity dates against its own clock, that issuers are marked as CAs and the leaf is allowed for server authentication, and finally that the leaf’s Subject Alternative Names include the host it asked for. As of September 2026 Mozilla’s store, which curl and most Linux distributions use, held 121 roots trusted for TLS (curl’s extract).
The classic failure is a server that sends only its leaf. Clients then disagree, because each fills the gap differently:
| Client | Missing intermediate |
|---|---|
| Chrome | Downloads it from the URL in the leaf’s Authority Information Access field |
| Firefox | Already has it: it preloads every intermediate disclosed to the CA database (Mozilla, 2020) |
| Any browser that visited another site with the same intermediate | Often has it cached |
| Python, Node, Go, Java, curl | Fail: “unable to get local issuer certificate” |
So the site opens in the browser, the developer’s API call fails, and the bug is reported against
the wrong side. The tls_checks example in Build it yourself reproduces it,
and shows that the error for a missing intermediate is identical to the error for an impostor
whose CA nobody trusts. That is why turning verification off to “fix” it is so dangerous: it fixes
the impostor case too.
Paths can also have more than one answer. CAs cross-sign: an intermediate may be signed by both a new root and an old one, so old devices that lack the new root still find a path. When the old root expires, a client that picks the expired path fails even though a valid one exists. That is what happened to older OpenSSL versions when Let’s Encrypt’s old root expired in September 2021 (Let’s Encrypt). Check chains with the clients you actually ship, not with a browser.
Revocation, which mostly does not work
Section titled “Revocation, which mostly does not work”If a private key leaks, the CA can revoke the certificate before it expires. The client then has to find out, and both ways of asking have failed in practice:
- A CRL (certificate revocation list) is a signed list of revoked serial numbers. Lists get large, and clients download them rarely.
- OCSP asks the CA about one certificate at connection time. It adds a network round trip, tells the CA every site you visit, and fails often enough that browsers proceed when there is no answer. That soft-fail behaviour is the fatal part:
- Step 1 of 5stolen cert
The attacker intercepts your connection and presents the bank's real certificate, signing with the stolen private key. The CA revoked that certificate yesterday. Every signature checks out.
- Step 2 of 5revoked?
The browser asks the CA. The request crosses the same network the attacker controls.
- Step 3 of 5drop it
The attacker drops the OCSP request.
- Step 4 of 5soft-fail
No answer could mean the CA is down, which happens, so the browser carries on as if the certificate were fine.
- Step 5 of 5The whole flow
Soft-fail revocation stops only attackers who cannot block traffic, and an attacker using a stolen key on your path can always block traffic. Hard-fail would turn every CA outage into an outage for every site it serves.
OCSP stapling lets the server fetch a signed OCSP answer and send it in the handshake, fixing privacy and latency, and Must-Staple marks a certificate as unusable without a stapled answer. Few sites used either. Let’s Encrypt, the largest CA, stopped accepting Must-Staple requests in May 2025 and shut its OCSP service down on 6 August 2025, keeping only CRLs (Let’s Encrypt).
Browsers moved the work to themselves. Chrome ships CRLSets, a curated list of revocations
pushed with browser updates, aimed at emergencies rather than every revoked certificate. Firefox
ships CRLite, a compressed encoding of every revocation of every certificate in the
Transparency logs, updated twice a day and checked locally; it is on for desktop users since
Firefox 137, and Firefox 142 stopped making OCSP queries for ordinary domain-validated certificates
(Mozilla, August 2025).
Your back-end code is in a worse position: Python’s ssl, Node’s tls and most HTTP clients check
no revocation at all by default.
So for most clients, the only revocation that always works is expiry. A stolen key is usable for the rest of the certificate’s life. With 398-day certificates that was up to 13 months; with 47-day certificates it is under 7 weeks. That is the main reason lifetimes are being cut.
Lifetimes falling to 47 days, and what breaks
Section titled “Lifetimes falling to 47 days, and what breaks”In April 2025 the CA/Browser Forum, where CAs and browsers set the rules for public certificates, passed a schedule that cuts the maximum lifetime in steps (ballot SC-081v3):
The same ballot shortens how long a CA may reuse a past domain validation (proof that you control the name) to 10 days by 2029. Automated renewal runs on ACME (RFC 8555), the protocol certbot and similar clients speak to a CA: the CA hands out a random token, and the client proves control of the name by serving it at an HTTP path, in a DNS record or in a TLS response. In practice, nearly every renewal must now pass a fresh challenge, so that path must work every time, not once a year. As of October 2026, Let’s Encrypt issues 90-day certificates by default, moves to 64 days on 10 February 2027 and 45 days in February 2028, and recommends renewing at about two-thirds of the lifetime, ideally when the CA’s ARI (ACME Renewal Information) endpoint says so (Let’s Encrypt). It also stopped sending expiry reminder emails in June 2025.
Renewing at two-thirds means a third of the lifetime is left when renewal starts. That third is not spare time for the automation. It is the time humans have to notice and fix broken automation. Automation breaks for dull reasons: a firewall change blocks the challenge, a DNS API token expires, a server is rebuilt without the renewal job, the proxy is never reloaded. Here are six certificates over two years, with those breakages:
With the defaults, 47-day certificates, about one breakage per certificate per year, an alert at 14 days left and three days to fix, the run issues 139 certificates, the automation breaks 9 times, and no certificate expires. Each break is a short amber band: renewal overdue, alert, fix, renewed with days to spare. Set the alert to 0 and the same 9 breaks cause 27 days with an expired certificate, because nothing tells a human until users do. Drag the lifetime to 6 days: the window is 2 days, a 3-day fix arrives after expiry, and the run has 12 days of outage with the alert on; it takes a 1-day fix to get back to zero. Short lifetimes shrink the damage of a stolen key, and the price is that humans have to answer faster.
What breaks first when lifetimes fall is not ACME. It is every place a certificate was copied by hand: uploaded to a CDN or an appliance, pasted into a partner’s allow-list, pinned in a mobile app, imported into a Java keystore. At 398 days that was one chore a year per place; at 47 days it is at least eight. Find those places now, and make each one either automated or fetch its certificate from somewhere that is.
Mutual TLS in a service mesh
Section titled “Mutual TLS in a service mesh”In mutual TLS (mTLS) the server adds a CertificateRequest to its flight, and the client answers
with its own certificate and its own CertificateVerify signature over the transcript. Same
mechanism, both directions: now the server knows which workload is calling, not only that someone
holds a token. Inside a service mesh such as Istio or Linkerd, a proxy next to each workload does
this for you, with an identity written as a URI in the certificate, by the SPIFFE convention:
spiffe://cluster.local/ns/shop/sa/orders. As of October 2026, Istio issues workload certificates
for 24 hours by default, from a key generated inside the pod
(Istio security).
Four ways a mesh’s mTLS is weaker than it looks:
- Authenticated is not authorized. A valid certificate says who is calling. “Any workload with
a certificate from our CA may call anything” is a policy, and usually the wrong one. The
mtlsexample in Build it yourself refuses a perfectly authenticated caller. - Permissive mode accepts plaintext. Istio’s
PERMISSIVEmode, the default, accepts both mTLS and plain text so services can migrate one at a time. Until a namespace isSTRICT, an attacker inside the network can skip mTLS entirely by speaking plain HTTP. - The application trusts a header. The sidecar ends mTLS and talks plain HTTP to the app on
localhost, passing the caller’s identity in a header such as
X-Forwarded-Client-Cert. If the app’s port is reachable around the sidecar, anyone can send that header. Envoy strips a client-supplied one by default (Envoy docs); make sure the app listens only on localhost. - The mesh CA becomes a hard dependency. With 24-hour certificates, a CA outage longer than the remaining lifetime is an outage of every service that cannot renew, at once. Monitor the CA like the database, not like a tool.
Where TLS ends inside your system
Section titled “Where TLS ends inside your system”A request may cross a CDN, a load balancer, an API gateway and a sidecar before reaching your code, and each can decrypt it. Decrypting at a proxy is TLS termination, and where it happens decides who can read your traffic.
| Edge only | Re-encrypt | Passthrough | Mesh mTLS | |
|---|---|---|---|---|
| Plain text inside the network | yes | only inside the proxy | no | no |
| Proxy can route on path and headers | yes | yes | no, only on SNI | yes |
| Private keys live on | the edge | edge and services | services only | edge, plus a short-lived key per workload |
| Callers identified by certificate | no | no | no | yes |
| Typical use | legacy | most web apps behind a managed balancer | databases, end-to-end rules | many services, zero-trust networks |
“Re-encrypt” only helps if the proxy verifies the service’s certificate; otherwise it is
verify=False on the back half. CDNs make this choice explicit. Cloudflare’s Flexible mode is
HTTPS to the visitor and plain HTTP to your origin; Full encrypts to the origin without checking
its certificate; only Full (strict) verifies it
(Cloudflare SSL modes). A
Flexible-mode site whose origin redirects HTTP to HTTPS loops until the browser gives up, and teams
“fix” that by removing the redirect.
Cloud providers encrypt some links for you; AWS encrypts traffic between its zones and regions and, on some instance types, between instances. Its documentation still leaves TLS to you (AWS EC2 data protection), because a cable that is encrypted does not stop a compromised machine on the same network, and it identifies nobody.
Build it yourself
Section titled “Build it yourself”Two programs that run against a private CA built by make-certs.sh: a root, an intermediate, a good
server certificate, an expired one, one for the wrong name, an impostor’s from a CA nobody trusts,
and two client certificates. All files are in
examples/tls/.
Step 1: what a client checks, and what verify=False removes
Section titled “Step 1: what a client checks, and what verify=False removes”A local server presents each certificate in turn. The client trusts only our root and sends a
password, first with the default checks, then with the “fix” people paste when they see
CERTIFICATE_VERIFY_FAILED:
# runnable version: examples/tls/python/tls_checks.pydef main() -> None: # The right client: default checks on, trusting only the CA we chose. strict = ssl.create_default_context(cafile=CERTS / "root.pem")
print("Client with verification on (the default):") for label, chain, key in CASES: try: result = connect(serve_once(chain, key), strict) except ssl.SSLCertVerificationError as err: result = f"REFUSED {err.verify_message}" print(f" {label:<27} {result}")
# The "fix" people paste from the internet when they see CERTIFICATE_VERIFY_FAILED. lax = ssl.create_default_context() lax.check_hostname = False lax.verify_mode = ssl.CERT_NONE
print("\nClient with verify=False (CERT_NONE, no hostname check):") for label, chain, key in CASES: print(f" {label:<27} {connect(serve_once(chain, key), lax)}")// runnable version: examples/tls/js/tls-checks.mjs// The right client: checks on (the default), trusting only the CA we chose.console.log('Client with verification on (the default):');for (const [label, chain, key] of CASES) { console.log(` ${label.padEnd(27)} ${await connect(await serveOnce(chain, key), { ca: read('root.pem') })}`);}
// The "fix" people paste from the internet when they see UNABLE_TO_VERIFY_LEAF_SIGNATURE.console.log('\nClient with rejectUnauthorized: false:');for (const [label, chain, key] of CASES) { console.log(` ${label.padEnd(27)} ${await connect(await serveOnce(chain, key), { rejectUnauthorized: false })}`);}Client with verification on (the default): good: leaf + intermediate OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2' missing intermediate REFUSED unable to get local issuer certificate expired REFUSED certificate has expired wrong name REFUSED Hostname mismatch, certificate is not valid for 'localhost'. impostor's own CA REFUSED unable to get local issuer certificate
Client with verify=False (CERT_NONE, no hostname check): good: leaf + intermediate OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2' missing intermediate OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2' expired OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2' wrong name OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2' impostor's own CA OK TLSv1.3 TLS_AES_256_GCM_SHA384 -> 'server got: password=hunter2'Read the last line. With verification off, the impostor received the password over TLS 1.3 with
AES-256: a modern protocol and a strong cipher, encrypting all the way to the attacker. That is
Mallory’s attack again, with real TLS. Node prints the same outcomes with its own error codes
(UNABLE_TO_VERIFY_LEAF_SIGNATURE for both the missing intermediate and the impostor). When you see
that error, run openssl s_client -connect host:443 -servername host -showcerts and count the
certificates the server sends.
Step 2: mutual TLS with authorization by identity
Section titled “Step 2: mutual TLS with authorization by identity”A payments service that requires a client certificate, reads the caller’s SPIFFE ID from it, and checks what that caller may do:
# runnable version: examples/tls/python/mtls.pyALLOWED = { "spiffe://shop.internal/orders": {"POST /charge"}, "spiffe://shop.internal/reports": {"GET /summary"},}
def caller_identity(cert: dict) -> str | None: """The URI in the certificate's Subject Alternative Name, e.g. spiffe://shop.internal/orders.""" return next((value for kind, value in cert.get("subjectAltName", ()) if kind == "URI"), None)
def payments_server() -> int: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(CERTS / "server-fullchain.pem", CERTS / "server.key") context.verify_mode = ssl.CERT_REQUIRED # this one line is what makes it *mutual* TLS context.load_verify_locations(CERTS / "root.pem") # client certs must chain to our root// runnable version: examples/tls/js/mtls.mjsconst ALLOWED = { 'spiffe://shop.internal/orders': new Set(['POST /charge']), 'spiffe://shop.internal/reports': new Set(['GET /summary']),};
/** The URI in the certificate's Subject Alternative Name, e.g. spiffe://shop.internal/orders. */const callerIdentity = (cert) => (cert.subjectaltname ?? '').split(', ').find((s) => s.startsWith('URI:'))?.slice(4) ?? null;
const server = tls.createServer({ cert: read('server-fullchain.pem'), key: read('server.key'), requestCert: true, // ask for a client certificate... rejectUnauthorized: true, // ...and refuse the handshake without a valid one: *mutual* TLS ca: read('root.pem'), // client certs must chain to our root}, (socket) => { const who = callerIdentity(socket.getPeerCertificate()); socket.once('data', (data) => { const allowed = ALLOWED[who]?.has(data.toString().trim()); socket.end(`${allowed ? '200 OK' : '403 Forbidden'} for ${who}`); });}); no cert POST /charge -> refused: TLSV13_ALERT_CERTIFICATE_REQUIRED orders POST /charge -> 200 OK for spiffe://shop.internal/orders reports GET /summary -> 200 OK for spiffe://shop.internal/reports reports POST /charge -> 403 Forbidden for spiffe://shop.internal/reportsWithout a certificate the handshake fails, and the client only learns it on its first read: in TLS
1.3 the client finishes its side before the server has checked the client’s certificate, so a
connection that “succeeded” can still be refused one step later. The last line is the one that
matters: reports holds a valid certificate and is still refused a charge.
Gotchas and war stories
Section titled “Gotchas and war stories”An expired certificate on a monitoring or security tool pages nobody, because nothing user-facing breaks. It fails silently and open. Put internal tooling on the same served-certificate monitor as the public site.
- Renewed on disk, old one in memory. ACME clients write files; nginx, HAProxy, Java services and many SDKs read certificates only at startup. Reload after renewal, and check what is served.
verify=Falsein a codebase is an incident, not a code smell. It almost always arrived as a fix for a missing intermediate or a corporate proxy root. Fix the chain, or add that one root to that one client’s trust bundle.- Containers carry their own trust stores. A slim image without CA certificates, or with a stale bundle, fails every outbound call after a CA changes its chain. Treat the CA bundle as a dependency you update.
- Infrastructure code picks old policies. As of October 2026, an AWS Application Load Balancer
created in the console defaults to a TLS 1.2 and 1.3 policy with post-quantum key exchange, while
one created through the CLI or CloudFormation without a policy still gets
ELBSecurityPolicy-2016-08, which allows TLS 1.0 (AWS docs). Always name the policy. - Identical expiry everywhere is one point of failure. The Azure outage above hit every region at once because every region held the same certificate. Stagger renewals.
Trade-offs and when not to use it
Section titled “Trade-offs and when not to use it”What TLS costs. One round trip for a new connection in TLS 1.3 and little CPU: when Gmail moved everyone to HTTPS in 2010 it cost under 1% of front-end CPU (Langley, Modadugu and Chang). The real cost is operations: keys to protect, certificates to renew, deploy and monitor, ticket keys to rotate, and a class of outage, expiry, that plain text never has.
What it does not solve. The endpoints: after decryption, a compromised machine reads everything. Data at rest. Authorization. Trustworthiness: phishing sites have valid certificates. Metadata: addresses, timing and sizes, and the site name unless ECH applies.
When not to reach for it alone. Inside one machine over a Unix socket it adds little. To protect a whole network link, including protocols that cannot speak TLS, a WireGuard or IPsec tunnel may be the better layer, alongside TLS rather than instead of it. Never build your own replacement: every broken scheme on this page was once someone’s careful design.
Interview corner
Section titled “Interview corner”- “How does the client know it is talking to the real server?” Chain to a trusted root, name match, dates, and above all the CertificateVerify signature over this handshake’s transcript, which includes both key shares. Follow-up: “So can a man in the middle never win?” Yes, with a certificate from a CA the client trusts: a compromised CA or an installed corporate root. Certificate Transparency makes the first visible.
- “What does forward secrecy protect, and what breaks it?” Recorded traffic, against later theft of the certificate key, thanks to ephemeral key exchange. Broken by RSA key exchange, by long-lived session ticket keys in TLS 1.2, and for 0-RTT data by ticket keys in TLS 1.3.
- “Would you turn on 0-RTT for this API?” Only for requests safe to run twice, because early
data can be replayed on new connections, and per-server anti-replay does not protect a cluster.
Mention
425 Too Earlyand idempotency keys. - “Where would you terminate TLS?” Edge versus re-encrypt versus passthrough versus mesh mTLS, trading the ability to route on paths against plain text inside, and who renews which certificates.
Check yourself
Section titled “Check yourself”1. Mallory relays your ClientHello to the bank unchanged and relays the bank’s whole reply back to you unchanged. What can she read?
Nothing. The key was agreed between your share and the bank’s share, and she knows neither private value. To read anything she must substitute a share, and then the bank’s CertificateVerify covers a transcript that does not match yours.
2. An attacker records traffic to a TLS 1.2 server that uses ECDHE and session tickets, and a year later steals the ticket key the server has used since it booted. What can they decrypt?
Every recorded connection whose session ticket they captured, including the full handshake that issued the ticket, because a TLS 1.2 ticket contains the master secret encrypted under that key. Ephemeral key exchange protected against theft of the certificate key, not the ticket key. Rotate ticket keys often.
3. Your Python service fails with “unable to get local issuer certificate” calling a partner API that opens fine in Chrome. What is the likely cause, and why is verify=False the wrong fix?
The partner’s server sends only its leaf; Chrome fetches the intermediate through AIA or has it cached, Python does not. Ask them to send the full chain, or add their intermediate to your trust bundle for that host meanwhile. The same error appears for an impostor’s certificate, so turning verification off accepts the impostor too.
4. You run 12 servers with a ClientHello replay cache in each, and accept POST in 0-RTT. An attacker sends 3 copies of a captured transfer. Roughly how many run?
The original plus three copies land on random servers. A copy is rejected only if it lands on a server that already saw that ClientHello. With 12 servers, most copies land somewhere new: four random picks reach about 3.5 distinct servers, so about 2.5 of the 3 copies run. The fix is a shared cache or, better, no POST in early data.
5. Your renewal automation breaks and is fixed within 5 days of an alert. With 47-day certificates renewed at two-thirds of their lifetime, what is the latest an alert can fire and still avoid an outage? What changes at 6 days?
Renewal starts with about 16 days left, so the alert must fire with more than 5 days left; 14 days leaves plenty of slack. With 6-day certificates renewal starts with 2 days left, so a 5-day fix cannot avoid expiry: the team must fix renewals within hours, or the lifetime is too short for them.
- The server proves itself with CertificateVerify, a signature over the handshake transcript including both key shares. The certificate alone proves nothing; anyone can send it.
- TLS authenticates whatever key a trusted CA vouched for: compromised CAs and installed roots defeat it, and Certificate Transparency makes the first visible.
- Forward secrecy comes from ephemeral key exchange and is undone by RSA key exchange and by long-lived session ticket keys. Rotate them.
- Encrypted traffic still shows addresses, the site name (without ECH), sizes and timing; sizes alone can name pages unless responses are padded.
- 0-RTT early data can be replayed on new connections; per-server caches do not protect a cluster. Accept only requests safe to repeat.
- Revocation mostly does not work; short lifetimes are the real fix, falling to 47 days by 2029. The renewal window is your human response time. Automate everything, alert on overdue renewals, monitor what is served.
- Know where TLS ends. Re-encrypt with verification, or use mTLS, and remember that mTLS authenticates; your code still authorizes.
Where to go next
Section titled “Where to go next”Further reading
Section titled “Further reading”Specs & official docs
- RFC 8446, TLS 1.3, and its July 2026 revision RFC 9846 — the handshake, the key schedule, and section 8 on 0-RTT anti-replay.
- RFC 10024, hybrid post-quantum key agreement — X25519MLKEM768 and its share sizes.
- RFC 9849, Encrypted Client Hello — hiding the server name, and when it does not help.
- RFC 8470, using early data in HTTP —
Early-Dataand425 Too Early. - RFC 6962, Certificate Transparency and Chrome’s CT policy — public logs of every certificate.
- RFC 8555, ACME — the protocol behind automated certificates.
- CA/Browser Forum ballot SC-081v3 — the schedule from 398 to 47 days and 10-day validation reuse.
- Let’s Encrypt: 64-day certificates, ending OCSP and certificate lifetimes — the largest CA’s current plans.
- Istio security concepts and Envoy header handling — workload identity, mTLS modes, and the client certificate header.
- Cloudflare SSL modes and 0-RTT — termination choices and early data at a CDN.
- AWS EC2 data protection and ALB security policies — what the cloud encrypts for you, and default policies.
- curl: CA certificates extracted from Mozilla — the trust store we counted.
Engineering blogs, papers & incidents
- Adam Langley: How to botch TLS forward secrecy (2013) and Filippo Valsorda: We need to talk about session tickets — ticket keys as long-term keys.
- Miller et al.: I Know Why You Went to the Clinic (PETS 2014) — naming HTTPS pages from traffic shape, and the cost of defending.
- breachattack.com — BREACH, compression as a size side channel.
- Mozilla: CRLite in Firefox (2025) and intermediate preloading (2020) — revocation and chain building done in the browser.
- Adam Langley: Revocation still doesn’t work (2014) — soft-fail and CRLSets from Chrome’s side.
- Cloudflare: post-quantum adoption on Radar (2026) and tldr.fail — hybrid key exchange in the wild, and the middleboxes it broke.
- Langley, Modadugu and Chang: Overclocking SSL (2010) — the measured cost of HTTPS for Gmail.
- Fox-IT: DigiNotar interim report (2011) — the CA compromise behind Certificate Transparency.
- GAO-18-559, Equifax and InfoQ on the 2013 Azure outage — two expired certificates, two very different failures.
- MUSCULAR — tapping private links inside a company network.
Explainers
- The Illustrated TLS 1.3 Connection — every byte of a real handshake, annotated.
- crt.sh and badssl.com — search Transparency logs, and test clients against broken certificates.
- Let’s Encrypt: DST Root CA X3 expiration (September 2021) — the root expiry and which clients broke.
Senior Dev Notes
System design, explained like a mentor would.
Written by Uday. Every page is researched, reviewed by a human, and every code example was run before it was published.