Skip to content

Encrypted Client Hello (ECH): what it hides and what it doesn't

· 20 min read
Copyright: MIT
ECH wordmark in monospace with an orange lock-and-envelope motif

For most of TLS’s life, the first thing a client said on a new connection told a passive observer exactly where it was going. The Server Name Indication extension carries the hostname in cleartext, by design, so a server hosting many sites behind one IP can pick the right certificate. Everything else in the handshake got encrypted over the years. The certificate moved under encryption in TLS 1.3. The DNS lookup that preceded it can ride over DoH. But the SNI stayed in the clear, a single readable string on the very first packet, and that string was enough for a censor or a network operator to know which website you were reaching even when they could read nothing else.

Encrypted Client Hello is the attempt to close that last gap. The idea is simple to state and surprisingly fiddly to build: take the real ClientHello, the one with the true SNI and the full extension list, encrypt it under a public key the server published in DNS, and wrap it inside a second, deliberately boring ClientHello that names a shared front-end instead. A passive observer sees the boring one. The question this post answers is what that actually buys you, because the honest answer is “less than the marketing suggests, but more than nothing, and the gap between those two depends entirely on who else is hiding behind the same front door.”

Below: how the inner and outer ClientHello split works, where the HPKE key comes from and how it gets into DNS, the exact fields the outer message still exposes, the accept-or-reject signal the server sends back, why GREASE ECH exists, and the deployment picture in 2026 now that the spec is finally an RFC. The through-line is the anonymity-set problem, which is the thing that decides whether ECH protects you or just relocates the tell.

The split: inner ClientHello, outer ClientHello

ECH started life as Encrypted SNI (ESNI), and the rename matters because it marks a real change in scope. ESNI encrypted only the SNI extension. That turned out to be too narrow: other extensions leak too. The ALPN list tells an observer whether you are speaking HTTP/2 or HTTP/3 or something else, the supported_groups and signature_algorithms reveal client identity in the same way SNI does, and the whole ordered extension block is a fingerprint in its own right. So the design changed to encrypt the entire ClientHello, every extension, not just the one field. The TLS ClientHello field reference walks through exactly which of those fields carry identity, and it is most of them.

The mechanism is a nested pair of ClientHello messages. The client builds two of them. The first is the ClientHelloInner, which is the message it actually wants to send: real SNI, real ALPN, full extension list. This one never travels in the clear. The second is the ClientHelloOuter, a complete and valid ClientHello in its own right, carrying innocuous values in the sensitive fields and a server_name that names a shared client-facing server rather than the real destination. The encrypted inner message rides inside the outer one, packed into an extension called encrypted_client_hello.

What goes on the wire ClientHelloOuter (cleartext) server_name: cloudflare-ech.com generic ciphers / ALPN encrypted_client_hello ext config_id, cipher_suite enc = encapsulated key payload = AEAD(inner) ClientHelloInner (never sent in clear) server_name: rutracker.org real ALPN, groups, sig_algs, key_share encrypted with HPKE under the server's ECH public key *The outer ClientHello travels in cleartext and names a shared front-end; the real ClientHello is AEAD-encrypted and carried inside the encrypted_client_hello extension. The hostname example follows the FOCI 2025 measurement paper.*

The outer message has to be a real, standalone ClientHello because of the failure mode. If the server does not support ECH, or holds the wrong key, or the config the client used is stale, the handshake still needs to complete somehow. So the server can fall back to negotiating against the outer ClientHello and serving a certificate for the public name. From a passive observer’s seat, whether the server decrypted the inner message or fell back to the outer one is indistinguishable. The bytes on the wire look the same either way. That indistinguishability is the whole point, and it is also where the design gets subtle.

Where the key comes from: HPKE and the DNS HTTPS record

The client cannot encrypt anything without a public key, and it needs that key before it sends a single byte of TLS. ECH solves this by publishing the key in DNS, inside the HTTPS resource record (RR type 65, the SVCB family). When a client looks up a host, alongside the address records it can receive an HTTPS record whose parameters include an ech value: the serialized ECHConfig.

The ECHConfig is the contract between client and server. It carries the HPKE public key, a list of HPKE cipher suites the server will accept (a KDF and an AEAD), a one-byte config_id so the server can tell which key a given ciphertext was encrypted under, the public_name that the outer ClientHello will advertise, and a maximum_name_length used for padding. The bootstrapping of all this through DNS service bindings is its own draft, draft-ietf-tls-svcb-ech, which pins down exactly how the ech SvcParam rides in the HTTPS record.

The encryption itself is Hybrid Public Key Encryption, RFC 9180. HPKE is the general-purpose sealed-box construction: the client generates an ephemeral key pair, runs a key-encapsulation against the server’s public key to derive a shared secret, and uses that secret to key an AEAD that encrypts the payload. The encapsulated ephemeral public key travels alongside the ciphertext so the server can re-derive the same secret with its private key. In ECH the HPKE context is set up with a specific info string, the literal bytes tls ech, a zero byte, and then the serialized ECHConfig. Binding the config into the info string means a ciphertext made for one config cannot be silently reinterpreted under another.

Client DNS resolver (DoH) TLS server HTTPS RR query for host ech = ECHConfig (pubkey, config_id, public_name) client seals inner ClientHello with HPKE under pubkey ClientHelloOuter { encrypted_client_hello } ServerHello (accept signal in random) *The key is fetched from DNS before the handshake. If that DNS lookup is itself in cleartext, an observer reads the hostname there and ECH protects nothing. This is the dependency that decides everything.*

Notice the ordering problem baked into that diagram. The client must perform a DNS lookup, in which the hostname appears, before it can do ECH at all. If that lookup goes out over plaintext Do53, a passive observer reads the name there and the encrypted SNI is moot. ECH therefore only delivers its promise when paired with encrypted DNS, DoH or DoT. The two protocols are complementary by construction, and a censor who blocks the DoH resolver has blocked ECH without ever touching the TLS layer. China and Iran do exactly this, which the measurement work below makes concrete.

How the inner message is packed: outer extensions and padding

A naive nesting would put the full inner ClientHello inside the outer one and send both. That roughly doubles the handshake size and, worse, makes the outer ClientHello’s size correlate with the inner one’s, handing a traffic-analysis signal straight back. ECH avoids this with two mechanisms worth knowing about.

The first is extension compression. Many extensions are identical between the inner and outer ClientHello (the same key_share, the same supported_versions, and so on), so duplicating them is waste. The inner message can replace those with a single ech_outer_extensions extension that lists, by type, the extensions to copy in from the outer ClientHello. The referenced values have to be contiguous in the inner message and the placeholder inserted in the right position, so that the server can reconstruct the exact byte sequence the client authenticated. The encoded form of the inner ClientHello, the thing that actually gets encrypted, is EncodedClientHelloInner: the compressed ClientHello followed by a run of zero bytes for padding.

That padding is the second mechanism, and it is the part people skip. If the ciphertext length tracked the real hostname length, a censor could distinguish a four-character SNI from a forty-character one and narrow the set of possible destinations. So the client pads. The rule in the spec: if the inner server_name has length D, the client adds enough padding to bring it up to the maximum_name_length the server advertised in its ECHConfig, then rounds the whole EncodedClientHelloInner up to a multiple of 32 bytes. The padding is not cosmetic. It is what keeps the encrypted payload from leaking the length of the thing it encrypts, and a deployment that sets maximum_name_length too low for its actual hostnames undermines its own users.

The accept-or-reject signal

The server receives the outer ClientHello, finds the encrypted_client_hello extension, reads config_id to pick the right private key, and attempts HPKE decryption. Two things can happen. It decrypts the inner ClientHello and proceeds with that. Or it cannot (wrong key, stale config, or it simply does not do ECH for this name) and it proceeds with the outer ClientHello, serving a certificate for the public name. The client needs to learn which happened, and it needs to learn it without that signal being visible to a passive observer.

The trick is in the ServerHello. When the server accepts ECH, it does not add a flag or an extension that an observer could read. Instead it overwrites the last eight bytes of the ServerHello.random with an accept_confirmation value derived via HKDF from the handshake transcript and the inner ClientHello’s client_random. The client, which knows the inner random, recomputes those eight bytes and checks them. A match means accepted. No match means the server fell back, and the client knows to either use the outer connection or trigger a retry. To anyone watching the wire, ServerHello.random is supposed to look random; eight bytes of it carrying a covert confirmation is invisible. The HelloRetryRequest path has its own confirmation carried in an encrypted_client_hello extension on the retry, with similar care taken.

The reject path is where the public name earns its keep. If the client’s config was stale, the server can fall back, complete a handshake authenticated against the public name’s certificate, and hand the client a fresh ECHConfig inside that now-encrypted channel via the retry_configs field. The client throws away the failed attempt and retries ECH with the new key. This is what lets servers rotate keys without breaking clients holding old DNS records: a stale config is a recoverable miss, not a hard failure. The cost is one extra round trip on the unlucky connections.

send outer ClientHello check last 8 bytes of ServerHello.random match ECH accepted no match fell back to public_name; retry_configs -> retry *The accept signal hides in eight bytes of ServerHello.random, so a passive observer cannot tell an accepted ECH handshake from a rejected one. Rejection is recoverable: the server can hand back a fresh config and the client retries.*

What the outer ClientHello still leaks

Now the honest accounting. ECH encrypts the inner ClientHello. It does not encrypt the network around it, and the outer ClientHello still carries real information.

The most obvious leak is the destination IP address. The TCP or QUIC packets go to an address, and that address is in every packet header. If a hostname resolves to a dedicated IP, encrypting the SNI accomplishes nothing, because the address is the name. ECH only helps when many names sit behind one front-end address, which is the CDN model: thousands of sites on a shared anycast IP, the SNI being the only thing that distinguished them. Take the SNI away and the observer is left with “a connection to Cloudflare,” which is the design goal. But the IP is still there, and a single-tenant origin gets no benefit at all.

The second leak is the public name in the outer SNI. The spec says the client SHOULD copy the ECHConfig’s public_name into the outer ClientHello’s server_name. So there is still a cleartext SNI on the wire. It just names the front-end instead of the destination. On Cloudflare that string is the literal cloudflare-ech.com, shared across every ECH-enabled site they host. The intent is that this name is uninformative, an entire CDN’s worth of sites collapsing to one label. The reality is that it is also a perfect blocking target, which the censorship section gets to.

The third leak is everything below TLS and everything timing-shaped above it. Packet sizes, inter-packet timing, the rhythm of a page load, the TLS record sizes once data flows: these are unchanged by ECH, and traffic analysis against them is an open problem that the protocol’s own authors flag rather than solve. ECH is not a defense against an adversary doing flow correlation. It removes one cleartext string. A determined observer with the IP, the timing, and the size profile can often still guess the destination, especially for high-traffic sites with recognizable load patterns.

There is a subtler leak too. The outer ClientHello is still a ClientHello, and it still has a TLS fingerprint. The cipher list, the extension ordering, the GREASE placement: all of that is visible on the outer message and identifies the client stack the same way it always did. ECH hides where you are going. It does nothing to hide what you are. A JA4 fingerprint reads the outer ClientHello fine, and the second-order tells that distinguish a real browser from uTLS or curl-impersonate are all still on the table. If anything, ECH narrows the field: a client that does ECH correctly, with the right config behavior and the right GREASE, is a smaller population than “everything that speaks TLS,” and getting the ECH behavior wrong is its own fingerprint.

GREASE ECH and the do-not-stick-out problem

If only the clients that had something to hide sent ECH, then sending ECH would itself be the signal worth blocking. A censor could drop every ClientHello carrying an encrypted_client_hello extension and force everyone back to cleartext SNI, paying only the cost of breaking the small ECH-using minority.

GREASE ECH is the countermeasure. The principle comes from GREASE generally (RFC 8701, Generate Random Extensions And Sustain Extensibility), and the GREASE-in-the-ClientHello post covers the original motivation. Applied to ECH, it means a client with no ECHConfig at all still sends a fake encrypted_client_hello extension on every connection: random config_id, a plausible cipher_suite, random enc and payload of believable sizes. The fake is indistinguishable on the wire from a real ECH attempt, because a real attempt is also just ciphertext. Firefox and Chrome both do this by default. The consequence is that an encrypted_client_hello extension in a ClientHello carries almost no information: it might be a real ECH handshake, it might be GREASE filler, and a censor cannot tell. Blocking on the extension’s presence means blocking essentially all Chrome and Firefox traffic, which is too much collateral for most censors to stomach.

This is the anonymity-set argument in microcosm. ECH protects you to exactly the degree that your traffic looks like a large, boring crowd’s traffic. GREASE manufactures the crowd at the extension-presence level. The crowd at the destination level is the public name, and that crowd is only as large as the number of sites sharing it. When the crowd is an entire CDN, ECH works. When the crowd is one site, ECH is theater.

Deployment in 2026: the spec shipped, the ecosystem did not catch up

The long saga of the standard ended in March 2026, when the IETF published RFC 9849, “TLS Encrypted Client Hello,” on the Standards Track. The document went through twenty-five drafts as draft-ietf-tls-esni before it became an RFC, and the authors are Eric Rescorla, Kazuho Oku, Nick Sullivan, and Christopher Wood. So the protocol is now stable and blessed. That is the good news, and it is genuinely good: implementers no longer have to track a moving draft.

The browser side has been live for a while. Chrome shipped ECH on by default from version 117, Firefox from 119, both in 2023. Firefox originally required DoH to attempt ECH and relaxed that in version 129, though it still recommends encrypted DNS for the privacy to mean anything. Safari has signaled intent to support it but trailed the other two. Both Chrome and Firefox send GREASE ECH by default, which is why the extension is now common on the wire regardless of whether any given connection is really using it.

The server side is the problem, and it is a sharp one. A peer-reviewed measurement study presented at FOCI 2025 surveyed the Tranco top one million over four months in early 2025 and reached a blunt conclusion: Cloudflare is the only major provider supporting ECH. Worse, nearly a third of Cloudflare’s own servers did not advertise an ECHConfig in DNS, which means browsers could not even attempt ECH against them. So the practical anonymity set for ECH in 2026 is, roughly, “sites on Cloudflare that happen to advertise it,” all sharing the single public name cloudflare-ech.com. Cloudflare enables ECH by default on free zones and does not let those zones turn it off; other plan tiers get a toggle. Self-hosting ECH remains rare because it needs server software that generates HPKE keys, publishes the ECHConfig in an HTTPS record, rotates keys, and decrypts the inner ClientHello, and few stacks do all of that cleanly.

2018 ESNI draft 2020 renamed to ECH 2023 Chrome 117, Firefox 119 2024 Russia blocks Cloudflare ECH 2026 RFC 9849 *Browsers shipped ECH years before the RFC; Russia blocked the Cloudflare deployment in November 2024, well before the standard was final. Standardization followed real-world use, which followed real-world blocking.*

When the anonymity set is one provider: the censorship story

The cleanest demonstration of ECH’s actual limits came not from a lab but from Roskomnadzor. On 5 November 2024, users in Russia started losing access to large numbers of Cloudflare-hosted sites. Two days later Russia’s network management center recommended that providers stop using Cloudflare, asserting that its ECH usage violated Russian law by aiding access to banned content. The blocking method, documented by the same researchers who later wrote the FOCI paper, is almost insultingly simple. Russian TSPU middleboxes drop any TLS connection that has both an encrypted_client_hello extension and an outer SNI of cloudflare-ech.com. That conjunction is the entire signature.

This is the anonymity-set failure made physical. GREASE ECH was supposed to make “has an ECH extension” useless as a blocking signal, and on its own it is. But the public name is not greased. Every real Cloudflare ECH connection advertises the same cloudflare-ech.com, so the censor adds that one fixed string to the match and the GREASE defense evaporates: GREASE traffic does not carry that SNI, so it is not caught, while real Cloudflare ECH traffic is. Cloudflare compounds the problem by rejecting ECH connections whose outer SNI is anything other than cloudflare-ech.com, which forecloses the obvious mitigation of randomizing the public name across a pool. One dominant provider, one fixed public name, one trivial filter.

The other two censors in the study did not even bother with the TLS layer. China and Iran block ECH by attacking the DNS step instead. Because the client must fetch the ECHConfig before it can do ECH, and because that fetch rides over an encrypted DNS resolver whose own endpoint has to be reached somehow, blocking the resolver blocks the config, which blocks ECH, all without inspecting a single ClientHello. ECH did not abolish SNI censorship. It relocated the censor’s job to encrypted DNS and to the outer SNI, the same way TLS relocated HTTP censorship onto SNI a decade earlier. The paper’s authors found they could still circumvent the blocking in all three countries with effort, but the default experience is that ECH, as deployed in 2026, is fragile against a motivated state censor precisely because its anonymity set is so thin.

What ECH is, honestly

ECH does one thing well and several adjacent things not at all, and conflating the two is how it gets oversold. It encrypts the inner ClientHello, including the SNI and the ALPN and the rest of the extension list, under an HPKE key fetched from DNS, and it hides the accept-or-reject signal so a passive observer cannot even tell whether ECH succeeded. Against a network operator logging SNI strings to build a profile of which sites you visit, on a CDN with a large shared front-end and encrypted DNS in place, that is a real and meaningful privacy gain. The cleartext hostname that survived every other TLS improvement finally goes dark.

What it does not do is hide your IP address, defeat traffic analysis, or change your TLS fingerprint, and it cannot rescue a single-tenant origin whose IP already is its name. Its protection is exactly the size of its anonymity set, and in 2026 that set is mostly one company’s advertised free-tier zones behind one fixed public name. The protocol is sound; the deployment is lopsided. RFC 9849 froze a good design in March 2026, but a good design with one major server-side deployment and a public name a censor can match on a single string is not yet the end of SNI privacy. It is a working mechanism waiting for a second large provider, a way to spread the public name across a pool, and encrypted DNS that a censor cannot pick off at the resolver. Until then, the most accurate thing to say about ECH is that it moved the tell. It did not erase it.


Sources & further reading

Further reading