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.
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.
enc, and the sealed inner hellosecret.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.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.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.One extension type, two shapes, chosen by the first byte of its body. This is the whole of ECH on the wire.
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.
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.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.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.
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.
max(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 = 120server_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.N = 31 - ((L - 1) mod 32) = 31 - (119 mod 32) = 31 - 23 = 8. 120 + 8 = 128, a multiple of 32.The inner hello would otherwise repeat every large extension the outer already carries. ech_outer_extensions (0xfd00) replaces them with a list of codepoints.
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.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.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.
The ech= value is a base64 ECHConfigList. Below is the same record as RDATA on the wire, with that list decoded in place.
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.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.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.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.
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.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.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 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.
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.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.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.
| signal | hidden? | why |
|---|---|---|
| SNI hostname | yes | Moved into the sealed inner hello. This is the whole point and it works. |
| inner ALPN, sigalgs, key_share | yes | Sealed with it, so the negotiated protocol is no longer readable client-side either. |
| JA4 of the outer hello | no | The 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 use | no | Extension 0xfe0d is right there in the clear. GREASE mitigates the population split, it does not remove the field. |
| destination IP | no | In every packet, below TLS. Only useful privacy if the address is shared by many origins. |
| JA4T, JA4L | no | The TCP SYN and the round-trip time are below TLS entirely. ECH cannot reach them. |
| sizes and timing | no | Record lengths and inter-packet timing are unchanged. Traffic analysis is unaffected. |
| the DNS query | only with DoH | The HTTPS RR lookup names the host. Plaintext DNS leaks it before the handshake starts. |
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.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.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.
| symptom | cause |
|---|---|
| ECH silently never engages | No HTTPS RR, or the resolver strips it, or encrypted DNS is off so the client declines to use ECH at all. |
| Constant retries | Key rotation faster than DNS TTL. The client keeps arriving with a config the server has already retired. |
| Works, then stops on one network | A 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 breaks | Working 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_name | The 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. |