PROTOCOLS-FUN . ECH

Encrypted ClientHello

RFC 9849
rev 2026.08
hides the destination
not the client
needs DNS and a crowd

This is the wire fighting back. The JA4+, TLS 1.3 and QUIC pages all rest on the same fact: the ClientHello is plaintext, so the SNI names where you are going and the extension shape names what you are. ECH is the countermove. It HPKE-seals the sensitive half of the ClientHello under a key fetched from DNS, and an on-path observer loses the hostname. It is a partial countermove and the page says so out loud. The outer ClientHello still has a JA4, the encrypted_client_hello extension is itself a fingerprintable feature, and the transport below is untouched. ECH hides the destination, not the client. Every struct below is built from RFC 9849 with every length computed. Hover a field to light its bytes, click a field marked + for its lookup table.

specRFC 9849, March 2026wasdraft-ietf-tls-esni, 25 revisions

A client using ECH builds two ClientHellos. The outer one goes on the wire in the clear and is deliberately boring: it names a generic public_name that the whole provider shares. The inner one carries the truth, gets sealed with HPKE, and travels as the payload of one extension inside the outer. The server decrypts it and answers the inner hello as though the outer never existed.

the same connection, two hellos
ClientHelloOuter · plaintext, on the wire
server_namepublic.example.net - the ECHConfig's public_name, shared by every origin behind this provider
cipher_suitesa generic list. It still fingerprints the client library.
key_sharea real share. The handshake must complete even if ECH fails.
ALPNpresent and readable. Only the inner ALPN is hidden.
ech (0xfe0d)type outer: cipher suite, config_id, enc, and the sealed inner hello
legacy_session_id32 bytes. The inner reuses this exact value.
ClientHelloInner · sealed, never on the wire
server_namesecret.example.com - the name you are actually asking for
cipher_suitesmay differ from the outer. Usually does not.
key_shareits own share. This is the one that keys the session.
ALPNthe real one. Hidden from the path.
ech (0xfe0d)type inner: a single byte, and nothing else
supported_versionsTLS 1.3 only. The inner hello may not offer anything older.
the inner hello wins, completely. If the server decrypts successfully, the handshake proceeds against ClientHelloInner and the outer is discarded - the certificate is for secret.example.com, the transcript uses the inner, and the outer exists only as a carrier and as AEAD associated data. The outer's public_name is never authenticated in a successful handshake. It matters in exactly one case, on the Reject & Retry tab, where it becomes the identity the server proves instead.
both hellos share one session id, and the encoding drops it. ClientHelloInner carries the same legacy_session_id as the outer, but the encoded form that actually gets sealed sets it to the empty string, and the server copies the outer's value back in during decoding. That saves 33 bytes of ciphertext for a field that is already visible, which is the same instinct behind the extension compression on the HPKE tab.
this only works because someone else is standing next to you. ECH moves the hostname out of the handshake; it does nothing about the destination IP address, which is in every packet. If secret.example.com resolves to an address that only it uses, an observer reads the name off the routing table and ECH has bought nothing. The privacy comes from the public_name naming a pool that thousands of origins share. ECH is a CDN-shaped mechanism, and that is a structural property, not a deployment detail.
codepoint0xfe0douter190 bytes hereinner5 bytes, always

One extension type, two shapes, chosen by the first byte of its body. This is the whole of ECH on the wire.

the outer form, inside ClientHelloOuter

A deliberately minimal example so every byte stays on screen. A real browser's payload runs roughly 200 to 300 bytes, because a real inner hello is bigger before padding.

the inner form, inside ClientHelloInner

Five bytes, and four of them are the extension header. The inner hello has to carry this so the server can tell, after decryption, that it is looking at an inner hello and not a replayed outer one.

config_id is one byte and it is a tracking surface. It exists so a server holding several keys can pick the right one without trial decryption. It is chosen by the operator and published in the ECHConfig, so every client using that config sends the same byte - which is fine, and is the point. It goes wrong when an operator issues per-client configs: the config_id then becomes a one-byte cookie in the clear, visible to exactly the observer ECH was meant to defeat. RFC 9849 warns about this directly, and it is the reason config_id should identify a key, never a user.
the payload length is a side channel, and padding is the answer. Ciphertext length tracks plaintext length, so an unpadded ECH would leak roughly how long the hidden hostname is. That is why the encoded inner hello is padded before sealing, using maximum_name_length from the config - the arithmetic is on the HPKE tab. It is also why the outer hello should be built to a similar shape whether or not ECH is real, which is what the GREASE tab is about.
primitiveHPKE, RFC 9180modemode_basesealsingle shot

Hybrid Public Key Encryption: an ephemeral KEM encapsulation to the server's public key, then an AEAD seal under the derived secret. The client sends the encapsulation as enc and the sealed hello as payload. No round trip, no forward secrecy for the config key itself.

the pipeline
// 1. build the encoded inner hello EncodedClientHelloInner = ClientHelloInner with legacy_session_id = "" server restores it from the outer with duplicated exts -> ech_outer_extensions 0xfd00, see below with padding appended see the arithmetic // 2. set up HPKE against the config's public key info = "tls ech" || 0x00 || ECHConfig the WHOLE config, verbatim enc, ctx = SetupBaseS(pkR, info) mode_base, no PSK, no sender auth // 3. seal, binding the outer hello as associated data ClientHelloOuterAAD = ClientHelloOuter with the ech payload field overwritten by zeros of the same length payload = ctx.Seal(ClientHelloOuterAAD, EncodedClientHelloInner)
the padding arithmetic, byte for byte

RFC 9849 §6.1.3, worked for the extension on the previous tab. M is maximum_name_length from the config, D the length of the inner hostname.

encoded inner74 bytes before any padding
name paddingmax(0, M - D) = max(0, 64 - 18) = 46, because secret.example.com is 18 characters. A shorter name gets more padding, so every name up to M produces the same length. 74 + 46 = 120
no name at allIf the inner hello omits server_name, the client adds M + 9 instead - the 9 covers the header of the extension that is not there, so its absence does not show either.
round upN = 31 - ((L - 1) mod 32) = 31 - (119 mod 32) = 31 - 23 = 8. 120 + 8 = 128, a multiple of 32.
AEAD tag+16 bytes. payload = 144, which is the length field at bytes 44-45 of the extension.
extension compression

The inner hello would otherwise repeat every large extension the outer already carries. ech_outer_extensions (0xfd00) replaces them with a list of codepoints.

// inside EncodedClientHelloInner, in place of the real extensions fd 00 ech_outer_extensions 00 09 extension_data length 08 OuterExtensions list length in bytes 00 0a 00 0d supported_groups, signature_algorithms 00 33 00 2b key_share, supported_versions // the server splices the real extensions back in from ClientHelloOuter
the compression has rules, and they are security rules. The referenced extensions must appear contiguously in the inner hello and in the same relative order in the outer; no extension may be referenced twice; and encrypted_client_hello may never be referenced at all. That last one is not an optimisation detail - allowing it would let the inner hello point at itself and give a decoder somewhere interesting to recurse. A server that finds any of these violated must abort rather than guess.
the AAD is what stops the outer hello being rewritten. The seal covers the entire ClientHelloOuter, with only the payload field blanked to zeros. So a middlebox cannot edit a single byte of the outer hello - not the cipher list, not the ALPN, not the public_name - without the server's decryption failing. This is why ECH resists tampering as well as observation: the visible half is cryptographically bound to the hidden half, and the only reason the payload itself is excluded is the obvious circularity.
no forward secrecy on the config key. HPKE gives the session forward secrecy through the TLS key schedule, but the seal on the inner hello is to a long-lived published key. Anyone who records handshakes today and later obtains that private key can decrypt the inner hellos and recover the hostnames retroactively. This is the argument for rotating ECH keys often, which servers do - hourly rotation is normal, and it is also what makes the stale-config path on the Reject tab a routine event rather than an error case.
recordHTTPS RR, RFC 9460SvcParamKey5 = ech

The client needs the server's public key before it sends its first byte, which makes ECH a DNS problem as much as a TLS one. The key rides in the same HTTPS resource record that already carries ALPN and address hints, so a client that is doing SVCB lookups gets it for free.

what dig shows you
$ dig +short secret.example.com HTTPS 1 . alpn="h3,h2" ipv4hint=192.0.2.1 ech="AEn+DQBFWwAgACCn8cDZTjtiWA+hx9MEnmslj DD5eh1uSwOS/Fpx6NQCYwAIAAEAAQABAANAEnB1YmxpYy5leGFtcGxlLm5ldAAA" ipv6hint=2001:db8::1

The ech= value is a base64 ECHConfigList. Below is the same record as RDATA on the wire, with that list decoded in place.

the record, byte by byte
the bootstrap problem: ECH over plaintext DNS is theatre. To learn the key for secret.example.com, the client asks DNS for secret.example.com. If that query is in the clear, the observer you are hiding from just read the name a few milliseconds before the handshake it can no longer read. ECH is only meaningful behind DoH, DoT or DoQ, and browsers wire the two together deliberately - Firefox will not use ECH unless DNS-over-HTTPS is active. The two features are one feature.
SvcParams are ordered, and that is checkable. RFC 9460 requires keys in strictly increasing numeric order: alpn 1, ipv4hint 4, ech 5, ipv6hint 6 in the record above. A resolver or middlebox that reorders them produces a record a strict client will reject. The mandatory ordering also means a parser can stop early, and that an observer can spot an ech key by position without decoding it.
the address hints are a privacy feature too, quietly. ipv4hint and ipv6hint let the client connect without a second A/AAAA lookup, which removes another plaintext query naming the host. They are hints, not authority - a client still validates the connection through the certificate - but skipping the extra lookup closes one more leak in the same window ECH is trying to protect.
specRFC 9849 §6.2purposemerge two populations

A client with no ECHConfig sends a fake one anyway: random config_id, a random string of the right length for enc, and a random payload of plausible size. The server cannot decrypt it, which is exactly what would happen with a stale real config, so it behaves identically.

why bother
the problemIf only ECH users send extension 0xfe0d, then sending it marks you. The set of people hiding their destination is smaller and more interesting than the set of everyone, and membership in it is visible in the clear.
the fixEveryone sends it. A client with a config sends a real one; a client without sends noise. The extension stops discriminating because its presence no longer correlates with anything.
what it costsA couple of hundred wasted bytes per handshake, and a server-side decryption failure it was already built to handle.
the giveaway to avoidA GREASE payload of a distinctive length, or a config_id drawn from a small set, re-separates the populations. The whole value depends on the fake being shaped like the real thing.
this is the same instinct as GREASE everywhere else, aimed at a different target. RFC 8701 GREASE exists to stop middleboxes ossifying on unknown values - send garbage so implementations stay tolerant. ECH GREASE exists to stop observers classifying on a known value - send garbage so the signal carries no information. Same mechanism, opposite adversary: one is aimed at broken software, the other at working surveillance.
a client that is GREASEing must ignore what comes back. The server will often answer an undecryptable ECH with retry_configs, because it cannot tell GREASE from a stale config - that indistinguishability is the point. A GREASEing client must discard them and carry on, because acting on them would prove it was not really using ECH, and would reintroduce exactly the distinction GREASE was added to erase.
alertech_required(121)carrierEncryptedExtensions

Keys rotate, caches go stale, and a client will regularly present a config_id the server no longer holds. This is expected traffic, not an error, so ECH has a defined path through it that leaks as little as possible.

the rejection path
client server
ClientHelloOuter + sealed innerconfig_id the server no longer hascleartext
cleartextServerHellorandom's last 8 bytes are NOT the accept signal
handshake keys{EncryptedExtensions + ech retry_configs}
{Certificate for public.example.net}
{CertificateVerify}{Finished}
verify the cert against the public_name
then abort: ech_required(121)the real request is never sent on this connectionhandshake keys
reconnect with the fresh config from retry_configs

The handshake completes before it is thrown away. That is deliberate: the client must authenticate the retry_configs it just received, and the only way to do that is to finish the handshake and check the certificate covers public_name. Configs from an unauthenticated connection would let anyone hand a client a key they control.

the acceptance signal is hidden in plain sight
// RFC 9849 7.2 - the last 8 bytes of ServerHello.random accept_confirmation = HKDF-Expand-Label( HKDF-Extract(0, ClientHelloInner.random), "ech accept confirmation", transcript_ech_conf, 8) // transcript_ech_conf = ClientHelloInner..ServerHello, with those // same 8 bytes zeroed first. HelloRetryRequest uses the label // "hrr ech accept confirmation" instead.
acceptance and rejection look identical to anyone watching. The server does not send a flag saying "ECH worked". It derives 8 bytes from a secret only a client holding the inner hello can compute, and writes them over the tail of ServerHello.random - a field that is supposed to be random, so it still looks random. An observer cannot tell a successful ECH handshake from a rejected one, which means it cannot tell which connections are worth attacking to force a downgrade. Rejection would otherwise be a very loud signal that the next connection is about to carry a real hostname.
the public_name is the only identity a rejected handshake proves. On rejection the certificate is for public.example.net, not for the site you wanted, and the client must validate it as such. It must then abandon the connection - sending the real request over it would deliver the hostname to whoever holds the public_name certificate. ech_required(121) is the alert that says "I will not continue without ECH", and a client that treats rejection as a soft failure has built the downgrade attack itself.
hidesthe destinationdoes not hidethe clientseeJA4+ breakdown

ECH removes one field from one layer. That field happens to be the most valuable one for censorship, which is why it matters - and it is not the field most fingerprinting actually depends on.

what survives ECH, layer by layer
signalhidden?why
SNI hostnameyesMoved into the sealed inner hello. This is the whole point and it works.
inner ALPN, sigalgs, key_shareyesSealed with it, so the negotiated protocol is no longer readable client-side either.
JA4 of the outer hellonoThe outer hello has its own cipher list, extension list, versions and sigalgs. JA4 computes over it exactly as before and still identifies the client stack.
that ECH is in usenoExtension 0xfe0d is right there in the clear. GREASE mitigates the population split, it does not remove the field.
destination IPnoIn every packet, below TLS. Only useful privacy if the address is shared by many origins.
JA4T, JA4LnoThe TCP SYN and the round-trip time are below TLS entirely. ECH cannot reach them.
sizes and timingnoRecord lengths and inter-packet timing are unchanged. Traffic analysis is unaffected.
the DNS queryonly with DoHThe HTTPS RR lookup names the host. Plaintext DNS leaks it before the handshake starts.
turning ECH on changes your JA4, which is the sharpest irony on this page. 0xfe0d is an ordinary extension: not GREASE, and not one of the two JA4 strips. So it is counted in the extension count and hashed into the c segment. An ECH-enabled build of a browser therefore has a different JA4 from the same build with ECH off - the privacy feature is itself a fingerprint bit. GREASE ECH is the answer, and it is a real one: if every client emits something at 0xfe0d, the bit stops discriminating. But it only works if the whole population participates, which makes ECH's client-side privacy a function of how many other people turned it on.
the SNI flag does not even flip. JA4's third character is d when server_name is present and i when it is absent. The outer hello still carries a server_name - it names the public_name - so an ECH connection still reads d. Nothing in the JA4 prefix announces that the name is a decoy. What changes is the extension count and the extension hash, not the SNI flag.
coherence still wins, and ECH raises its value. The JA4+ argument is that identity comes from agreement across layers rather than any single hash, and ECH does not touch that argument - it removes one field and leaves the cross-layer picture intact. If the TLS layer says one thing and the SYN says another, that contradiction is exactly as visible as it was before. What ECH genuinely defeats is the specific, cheap, enormously deployed practice of blocking on the hostname in the clear, and that is a real win worth having. It is not anonymity, and the honest framing is worth insisting on: ECH hides where you are going, not who you are.
statusRFC as of March 2026needsencrypted DNS + a pool

ECH shipped behind a long tail of preconditions, and most of what goes wrong is one of them being absent rather than the cryptography failing.

two server topologies
shared modeThe client-facing server is also the backend. It decrypts the inner hello and serves the request itself. Simple, and the common case for a single origin behind a provider.
split modeThe client-facing server decrypts and forwards the inner ClientHello to a separate backend, which terminates TLS. The front end never holds the backend's certificate key, so it learns the hostname but not the session. This is how a CDN offers ECH without holding every customer's private key.
what breaks, and why
symptomcause
ECH silently never engagesNo HTTPS RR, or the resolver strips it, or encrypted DNS is off so the client declines to use ECH at all.
Constant retriesKey rotation faster than DNS TTL. The client keeps arriving with a config the server has already retired.
Works, then stops on one networkA middlebox dropping or rewriting the HTTPS RR, or blocking the larger ClientHello. ECH adds a couple of hundred bytes and can push a hello past a size a broken box tolerates.
Enterprise inspection breaksWorking as designed. A TLS-inspecting proxy cannot select a certificate without the SNI, so ECH and SNI-based inspection are mutually exclusive by construction.
Blocked by public_nameThe countermeasure that actually gets deployed: a censor that cannot see the inner name blocks the shared front-end name or its address instead, taking every origin behind it down together.
the endgame is collateral damage, and both sides know it. ECH's defence is that blocking it means blocking the whole pool, and a pool large enough makes that politically expensive. That is not a cryptographic guarantee, it is a hostage-taking argument, and it holds exactly as long as the pool contains something the censor wants to keep. Countries have already blocked entire CDN front-ends rather than lose hostname visibility. ECH raises the price of blocking; it does not make it impossible, and the security model is honest about that.
where this sits. The handshake it modifies is on the TLS 1.3 page, the record that delivers the key is on the DNS page, the same ClientHello rides QUIC's Initial where it is readable by anyone who can derive the Initial keys from a cleartext connection ID, and the fingerprint it complicates but does not remove is the JA4+ breakdown.