Skip to content

How Encrypted SNI became ECH: the long road to hiding the hostname

· 18 min read
Copyright: MIT
Wordmark reading ESNI arrow ECH with the server_name field crossed out and an encrypted_client_hello blob

TLS 1.3 was supposed to close the book on handshake snooping. It encrypts the server certificate, so a passive observer can no longer read the name on the cert as it flies past. And yet, right at the start of every connection, the client still announces in cleartext exactly which site it wants. The field is called Server Name Indication, it sits in the very first packet the client sends, and anyone on the path can read it. Your ISP, the coffee shop router, a national firewall: all of them learn the hostname before a single byte of encrypted application data moves.

That one plaintext field has resisted fixing for the better part of two decades. The first serious attempt, Encrypted SNI, shipped as a live experiment in 2018, ran into trouble almost immediately, and was abandoned. Its replacement, Encrypted Client Hello, took six more years of drafts and only became RFC 9849 in March 2026. This post traces that road: why SNI leaks in the first place, what ESNI tried, the specific reasons it could not work, and how ECH redesigned the whole thing around encrypting the entire inner ClientHello instead of one extension.

The sections below move in order. First the original sin: how SNI got into TLS and why it stayed in the clear. Then the 2018 ESNI experiment and its DNS-key mechanism. Then the three things that killed it, the Pre-Shared Key leak, the trial-decryption and stale-key failure modes, and the way its hard-fail design made it trivial to fingerprint and block. Then ECH itself, with HPKE and the two-ClientHello structure. And finally where deployment actually stands in 2026.

2003-2016: why the hostname rides in the clear

SNI was added to TLS for a boring, practical reason. Before it existed, a server could only present one certificate per IP address, because the handshake gave it no way to know which of several hosted sites the client wanted before it had to send a cert. SNI fixed that. The client puts the target hostname in an extension in its ClientHello, the server reads it, and the server picks the matching certificate. It was specified in RFC 3546 in 2003, carried forward through RFC 4366, and is current in RFC 6066. Virtual hosting at scale depends on it. Every CDN, every shared-IP host, every cloud load balancer reads SNI to route the TLS termination.

The catch is structural. SNI has to be readable before the handshake produces any keys, because it is the input that selects which certificate and which keys to use. It is a chicken-and-egg problem. You cannot encrypt the thing that tells the server how to set up encryption, not without another layer underneath. So for fifteen years the hostname sat in the clear at the front of the connection, and everyone got used to it being there.

Plenty of infrastructure came to depend on reading it. Censors filter on it because it is the cheapest possible signal: no DNS lookup, no deep inspection, just match the SNI string against a blocklist and drop the packet. Enterprise middleboxes log it. Some CDNs and analytics pipelines key on it. The same property that made SNI useful for routing made it useful for surveillance, and that overlap is exactly why removing it is so contentious. Anti-bot and fingerprinting stacks read the ClientHello for other reasons too, which is its own long story covered in the TLS ClientHello field reference and TLS fingerprinting from ClientHello bytes to JA4.

TLS 1.3, finalised as RFC 8446 in 2018, encrypted most of the handshake after the ClientHello, including the certificate. That was a real privacy win. But the ClientHello itself goes out before any keys exist, so SNI stayed exposed. Closing that last gap needed a separate mechanism, and that is where the story actually starts.

client server ClientHello server_name = example.com plaintext, no keys exist yet ServerHello + {Certificate} encrypted from here on (TLS 1.3) application data: fully encrypted *TLS 1.3 encrypts the certificate, but the ClientHello goes out before any keys exist, so the server_name extension is still readable by anyone on the path.*

2018: the ESNI experiment

The first deployed answer was Encrypted SNI. Cloudflare turned it on across its network on 24 September 2018, and Firefox added opt-in support in its Nightly builds the same week, reaching stable behind a flag in Firefox 64. Apple, Fastly and Mozilla were all named as collaborators on the IETF draft. The pitch was simple and, on the surface, elegant: leave the rest of the handshake exactly as it was and encrypt only the one field that leaked.

The mechanism leaned on DNS. A server would publish a public key, and the client would fetch that key, derive a shared secret with an ephemeral key of its own, and encrypt the SNI under it before sending. In the deployed Cloudflare and Firefox version, the key lived in a DNS TXT record named _esni under the target domain, carrying a structure the draft called ESNIKeys. Later draft revisions moved this off TXT records into a dedicated resource record, but the shipping experiment used _esni TXT. The client put the encrypted blob, its own public key share, the chosen cipher suite, and a digest of the server’s ESNI record into a new ClientHello extension, codepoint 0xffce. Some of the ClientHello’s own parameters were mixed into the key derivation as additional data so the encryption was bound to that specific handshake.

Fetching that key over plaintext DNS would have defeated the point, since an observer watching your DNS queries already learns the hostname. So ESNI was coupled to encrypted DNS from the start. Firefox required DNS over HTTPS to be on before it would attempt ESNI, and Cloudflare had launched its 1.1.1.1 resolver with DoH and DNS over TLS support in April 2018, a few months earlier. The two pieces were meant to ship together: encrypt the DNS lookup, encrypt the SNI, and the hostname stops leaking at both layers. DNS-over-HTTPS has its own deployment story, told in DNS-over-HTTPS and DNS-over-TLS.

For a while it looked like it might just work. The encryption was real, the field was hidden, and the design touched very little of the existing handshake. That minimalism was the appeal. It was also the problem.

2019-2020: the three things that killed ESNI

ESNI never advanced past draft status, and it was deprecated rather than finished. Three distinct issues sank it. None of them were bugs in the cryptography. They were consequences of the decision to encrypt one field and leave everything else alone.

The Pre-Shared Key leak

Encrypting only the server_name extension assumes that extension is the only place the hostname appears. It is not. TLS 1.3 session resumption uses a Pre-Shared Key extension, and the contents of a PSK identity are opaque to the protocol: an implementation can put whatever it likes in there, including, in practice, a cleartext copy of the very server name that ESNI just finished encrypting. So a resumed connection could leak the hostname through the front door while ESNI locked the back one. The Mozilla writeup on moving to ECH used exactly this example. Once you see it, the whole one-field approach looks fragile. SNI was never the only metadata worth hiding; ALPN tells an observer whether you are speaking HTTP/2 or HTTP/3, and other extensions carry their own tells. Patch one leak and the others remain.

Trial decryption and stale keys

The second problem was operational. Because the server’s key lived in DNS, and DNS records get cached, clients routinely held keys that were out of date. ESNI’s answer to a key it could not use was blunt: if the server failed to decrypt the SNI, it aborted the connection. A hard failure. That turned a cache-consistency annoyance into a broken page. Cloudflare’s own experience was that DNS artifacts sometimes stay cached far longer than their stated lifetime, so stale ESNI keys were not a rare edge case.

There was a deeper structural issue underneath the caching. DNS gives you no way to atomically tie together the records describing one endpoint. A domain served by several frontends with different capabilities could hand a client an ESNI key that did not match the endpoint it actually reached, and again the handshake would fail. And on the server side, supporting multiple live keys meant trial decryption: try each known key against the incoming blob until one works, which adds cost and complexity precisely on the servers that rotate keys most aggressively.

Hard-fail made it a fingerprint

The third problem turned ESNI from a privacy tool into a censorship beacon. Because the extension was new and distinctive, it was easy to spot on the wire, and because the design failed hard when anything went wrong, simply blocking it was effective. The Great Firewall of China started dropping ESNI connections at the end of July 2020. The first blocking was observed on 29 July 2020 and reported the next day. A team including Kevin Bock and David Fifield documented exactly how it worked: the firewall watched for the 0xffce extension codepoint, and the mere presence of that codepoint was enough to trigger the block. Swapping the codepoint to an unused value such as 0x7777 made the connection sail through, which proved the firewall was matching on the extension number itself.

The blocking method was unusually blunt for the GFW. Instead of forging TCP reset packets to both ends, it simply dropped packets from client to server, and then kept dropping anything matching the same source IP, destination IP, and destination port for a residual window of 120 or 180 seconds. A privacy feature that is this easy to detect and this catastrophic to block is worse than useless to the user who turns it on: it makes their connections stand out and then breaks them. That is the opposite of what an anti-surveillance feature should do.

Why encrypting one field was not enough 1. PSK leak server_name: ████ encrypted pre_shared_key: example.com resumption can carry the name in cleartext 2. Stale key DNS _esni cached key rotated decrypt fails → abort hard failure breaks the page 3. Fingerprint extension = 0xffce GFW matches it, drops packets distinctive + hard-fail = easy to censor *Each failure mode follows from the same choice: encrypt the server_name extension and leave the rest of the ClientHello alone.*

2020: the redesign into ECH

The fix for all three problems was the same: stop encrypting one extension and encrypt the entire ClientHello. The extension was renamed in the spring of 2020, briefly to ECHO and then settling on ECH, Encrypted Client Hello, by May. The working group kept the same draft lineage, draft-ietf-tls-esni, which is why the document that became the ECH RFC still carries the old esni name in its identifier.

The core idea is two ClientHellos. The client builds the real one first, with the true server name, the real ALPN list, the cipher preferences it actually wants. This is the ClientHelloInner, and it never appears in the clear. The client then builds a second, decoy ClientHello, the ClientHelloOuter, filling the sensitive fields with innocuous public values, and carries the encrypted inner hello inside a new encrypted_client_hello extension on the outer one. The outer hello uses a public name, typically a name the client-facing server is willing to present a certificate for, so to a passive observer the connection just looks like ordinary TLS 1.3 to that shared frontend. The whole point is that an observer sees plausible, boring traffic and never sees which actual site the inner hello asked for.

The encryption was handed off wholesale to HPKE, Hybrid Public Key Encryption, standardised as RFC 9180. ESNI had rolled its own scheme; ECH delegates every cryptographic detail to HPKE, which made the spec smaller and far easier to analyse. The server’s ECH configuration, the ECHConfig, advertises an HPKE key configuration: a config identifier, a KEM identifier, the public key, and the cipher suites the client may use. The client picks a config, runs HPKE to seal the inner hello under the server’s public key, and ships the result.

Key distribution moved off TXT records and onto the DNS SVCB and HTTPS resource records defined in RFC 9460, with the ECH-specific details in RFC 9848. Those record types were built to describe a service endpoint and its parameters as a unit, which solves the atomic-binding problem that broke ESNI. The ECH key travels as one parameter of the HTTPS record alongside the rest of the endpoint’s configuration, so a client no longer ends up with a key that belongs to a different frontend than the one it reaches.

ClientHelloOuter what the network sees: looks like plain TLS 1.3 server_name = public-name.example (decoy / shared frontend) extension: encrypted_client_hello HPKE-sealed (RFC 9180) under the server's ECHConfig key ClientHelloInner server_name = real-target.example ALPN, ciphers, ... *The real hello is sealed with HPKE and tucked inside the decoy. An observer sees only the outer hello and its public name.*

How ECH fails soft

The single most important behavioural change from ESNI is what happens when the server cannot decrypt. ESNI aborted. ECH continues. If the server does not support ECH, or holds a key that no longer matches the client’s, it completes the handshake using the ClientHelloOuter, which the spec calls rejecting ECH. The connection still works, just without the privacy benefit, and the server can hand the client fresh keys in the form of retry_configs so the client can immediately reconnect with a current key. A stale DNS cache becomes a one-round-trip retry instead of a dead page. That soft-fail recovery is the property ESNI lacked, and it is the reason ECH can be deployed without breaking the long tail of clients and middleboxes that have never heard of it.

That same retry path is exactly where GREASE comes in. GREASE, originally RFC 8701, is the practice of sending plausible-looking but meaningless values to keep implementations honest, so that they ignore things they do not understand instead of hardcoding the set of values they currently see. ECH defines a GREASE ECH mode: a client that is not actually using ECH can still send a well-formed-looking encrypted_client_hello extension full of random bytes, a random config id, cipher suite, encapsulated key, and ciphertext. The server cannot tell that apart from a real ECH attempt against a key it does not have, so it does the only sane thing, which is reject and continue. The genius of this is that it forces servers to handle the reject path on connections that look ECH-ish whether or not ECH is real, which keeps the path exercised and hard to ossify.

GREASE also blunts the fingerprinting attack that killed ESNI. If a meaningful fraction of clients send GREASE ECH on every connection, then the presence of an encrypted_client_hello extension no longer tells a censor that this particular user turned on a privacy feature. It only tells them the client is recent. Blocking on the extension’s presence now means blocking a chunk of ordinary traffic, which raises the collateral cost of censorship considerably. The GFW report on ESNI blocking noted that ECH used different extension values that had not been blocked at the time of writing, and the deeper point is that ECH was designed so that blocking it cleanly is much harder than blocking ESNI was. This is the same GREASE machinery that scrambles TLS fingerprints elsewhere, covered in GREASE values in the ClientHello.

There is a cost to this softness, and the spec is honest about it. The lengths of the encrypted fields can still leak information through traffic analysis, and the mitigations for that are partial. ECH hides which name you asked for, not the fact that you opened a connection or how much data flowed. It is a metadata-confidentiality feature with sharp edges, not an anonymity system.

ESNI: server can't decrypt stale key abort broken page ECH: server can't decrypt stale key accept outer + retry_configs reconnect, works *ESNI's only move on a key mismatch was to abort. ECH completes the handshake on the outer hello and hands back fresh keys for an immediate retry.*

2021-2026: where deployment actually landed

ESNI was removed from Firefox in version 85, January 2021, and replaced with ECH at draft-08 in the same release; the Mozilla post announcing that move spelled out the reasons, the PSK leak example among them. From there ECH rode the draft revisions up to 25. Chrome enabled ECH by default in version 117 in September 2023, and Firefox followed with default-on ECH in version 119 in October 2023, both gated on the client being able to read the HTTPS DNS record. Cloudflare, which ran the original ESNI experiment, was an early ECH server operator. So the browser and CDN sides were largely in place years before the standard was final.

The RFC itself landed late. RFC 9849, “TLS Encrypted Client Hello,” by Eric Rescorla, Kaname Oku, Nick Sullivan and Christopher Wood, was published in March 2026 as a Proposed Standard, with the companion HTTPS-record details in RFC 9848 and the SVCB/HTTPS record framework in RFC 9460. The HPKE dependency, RFC 9180, had been done since 2022. Server-side support in the wider ecosystem arrived around the same time as the RFC: NGINX added ECH support in December 2025 and the OpenSSL 4.0 release in April 2026 carried it. The pattern is the one TLS features usually follow. Browsers and the big CDN deploy a late draft at scale, it bakes for a couple of years, and the RFC ratifies what already shipped.

The unfinished business is the same as it was in 2020. ECH hides the name but not the lengths, so traffic analysis on encrypted-field sizes is still a live concern the spec flags rather than solves. The privacy guarantee also leans on the public name being shared by enough sites that it does not itself identify the target; a frontend that serves one site gives the game away regardless of how well the inner hello is sealed. And the DNS dependency means the whole thing only delivers if the HTTPS record lookup is itself private, which loops back to DoH and DoT being deployed and used. ECH is one piece of a larger metadata-hiding effort, not a standalone fix.

What the road actually taught

The arc from ESNI to ECH is a clean case study in why minimal fixes to security protocols tend to fail. ESNI did the obvious thing: find the field that leaks, encrypt that field, change nothing else. It was smaller, simpler, and shipping in browsers two years before ECH had a stable draft. And it lost on every front that mattered. The hostname leaked anyway through PSK. The hard-fail design turned a DNS caching wrinkle into broken pages. And the distinctive, fragile extension made the feature a gift to censors, who could detect it on sight and block it for free.

ECH won by being less clever about the cryptography and more careful about the failure modes. Handing encryption to HPKE meant the working group could stop arguing about key schedules and spend its attention on the parts that actually broke ESNI: how to fail soft, how to recover from stale keys, how to keep the extension from becoming a fingerprint. The two-ClientHello structure with a shared public name, the retry_configs recovery path, and GREASE ECH all exist to answer questions ESNI never asked. That is the lesson worth keeping. A privacy feature is only as good as its worst failure mode, and for ESNI the worst case was a connection that stood out and then broke. The eight years between the 2018 experiment and RFC 9849 in 2026 were mostly spent learning that the hard part of hiding the hostname was never the encryption.


Sources & further reading

Further reading