The DNS lookup is encrypted either way. What differs is the site name in the TLS handshake: sent in the clear, the network reads it; encrypted with ECH, it cannot.
Encrypting DNS and encrypting the site name are two different jobs. Doing the first without the second leaves the most revealing detail, which site, in plain view.
The two acronyms, in plain words
- SNI — Server Name Indication
- A field in the TLS handshake where your browser writes the site name (for example dnsdoh.art) so a server that hosts many sites knows which one to answer for. By default it is sent in plaintext, before any encryption starts, so anyone on the network can read it. SNI is the thing being exposed.
- ECH — Encrypted Client Hello
- The feature that encrypts that opening handshake message, the part carrying the SNI and more. With ECH on, the site name is sealed inside and only the server can read it. There is no other way to encrypt the SNI today; ECH is the lock.
How the sealing works, in one line: the server publishes a public key in DNS, your browser uses that key to lock the handshake before sending it, and only the server, holding the matching private key, can unlock it. So the browser does the encrypting; the server just supplies the key and decrypts. The step-by-step is below.
Why the name is sent in the clear
It is not an oversight. A single IP address often hosts thousands of different sites behind a CDN or shared host. When your browser connects, the server has to know which site you want so it can present the right certificate, and it has to know this before the encrypted channel exists. So the browser announces the hostname in the very first message of the TLS handshake, a field called SNI (Server Name Indication), in the clear.
That solves the server's problem and creates yours. Your DNS query was encrypted, the page content is encrypted, but the one line that says where you are going travels unprotected. A network in the middle, your ISP, a workplace, an airport, does not need to break any encryption to log the sites you visit. It just reads the SNI.
From ESNI to ECH
The first attempt, ESNI, encrypted just the SNI field. It helped, but the rest of the handshake still leaked clues, and it was retired. Its successor, Encrypted Client Hello (ECH), takes the whole opening message of the handshake, the part that carries the SNI and more, and encrypts it with a public key that belongs to the server's front-end.
To an observer the handshake now starts with a generic, shared outer name instead of your real destination. Everyone connecting through the same front-end looks the same on the wire. The real site name is sealed inside, readable only by the server.
The key that makes ECH possible is published in DNS. That is why encrypted DNS and ECH belong together.
Why ECH needs encrypted DNS
Notice where the ECH key comes from: a DNS record (the HTTPS, or SVCB, record) for the site. Your browser has to look that up before it can build the protected handshake. If that DNS lookup happens in the clear, an observer watching it can often infer the destination anyway, which undoes much of what ECH set out to protect.
So the two are a pair. Encrypted DNS hides the lookup; ECH hides the handshake. Run one without the other and you have only closed half the gap. If you have not encrypted your DNS yet, that is the first step, our setup guide covers it, and the protocols comparison explains the options.
Check it yourself
Because the key lives in DNS, the whole server half of ECH is checkable with one command. Ask this site for its HTTPS record:
$ dig dnsdoh.art HTTPS +short
1 . alpn="h3,h2,http/1.1" ech=AEH+DQA9AgAgACBJKPwET/KL8YQLoeVc4Gl3
06d0vK1FgBb4llpxekrUEQAIAAEAAQABAAMACmRuc2RvaC5hcnQAAA==That ech= parameter is the key your browser needs. If it is absent, ECH cannot happen on that site no matter what your browser supports. The value above was read on 29 August 2026; this site rotates its ECH key monthly, so yours will not match character for character.
Run the same command against a few names and the state of deployment stops being a rumour. Three outcomes, all measured the same day against four different resolvers so the answers are not one resolver's opinion:
| Name | What its HTTPS record says |
|---|---|
| dnsdoh.art | record present, ech= present |
| crypto.cloudflare.com | record present, ech= present |
| defo.ie | record present, ech= present |
| cloudflare.com | record present, no ech= |
| google.com | record present, no ech= |
| example.com | record present, no ech= |
| github.com | no HTTPS record at all |
| wikipedia.org | no HTTPS record at all |
Worth sitting with for a second. cloudflare.com itself does not publish an ECH key, while crypto.cloudflare.com, the host Cloudflare keeps for testing it, does. Two of the eight names have no HTTPS record at all. ECH is real and it works, but on the day of writing it is the exception rather than the default, and the command above tells you which case you are in.
Look inside the key
The value is base64. Decode it and the structure is small enough to read by eye:
$ dig dnsdoh.art HTTPS +short \
| grep -o 'ech=[A-Za-z0-9+/=]*' | cut -d= -f2- | base64 -d | xxd
00000000: 0041 fe0d 003d 0200 2000 2049 28fc 044f .A...=.. . I(..O
00000010: f28b f184 0ba1 e55c e069 77d3 a774 bcad .......\.iw..t..
00000020: 4580 16f8 965a 717a 4ad4 1100 0800 0100 E....ZqzJ.......
00000030: 0100 0100 0300 0a64 6e73 646f 682e 6172 .......dnsdoh.ar
00000040: 7400 00 t..Reading it against the ECH specification: fe0d is the version codepoint, 0020 names the key exchange as X25519, the next 32 bytes are the public key itself, and the pair of cipher suites that follow are HKDF-SHA256 with AES-128-GCM and with ChaCha20-Poly1305. The last field is the interesting one: 0a then the ten bytes dnsdoh.art, in the clear, at the end of the record.
That is the public name, and it is supposed to be readable. It is the name that goes in the outer handshake, the cover story an observer sees instead of your real destination. On a large shared front-end that outer name is the same for thousands of sites and reveals nothing. On a single-site server like this one it names the site, which is precisely the limitation What ECH still does not hide covers further down. The key being public is not a weakness either: it encrypts, it does not decrypt, and only the server holds the private half.
See the leak that ECH exists to close
The claim at the top of this page is that your browser announces the site name in plaintext. You do not have to take it on faith. Capture your own TLS handshakes and print the one field:
$ sudo tshark -i any -f 'tcp port 443' \
-Y 'tls.handshake.type==1' \
-T fields -e tls.handshake.extensions_server_name
example.com
crypto.cloudflare.comEvery site you open, in plaintext, from a filter one line long. Nothing here is decrypting anything: this field travels in the clear before any encryption is negotiated, which is the entire problem. Note also what you cannot demonstrate this way: curl lists an --ech option but most builds are not compiled with support for it, and ours answers the installed libcurl version does not support this. Confirming that ECH was actually used on a connection is a browser job today, not a command-line one.
On Windows the DNS tools cannot answer this. PowerShell can.
This is worth knowing before you spend an evening on it. Neither DNS tool that ships with Windows can retrieve an HTTPS record, and they fail in two different ways. PowerShell refuses outright, and its error is a complete list of every record type it knows:
PS> Resolve-DnsName -Name dnsdoh.art -Type HTTPS `
-Server 194.180.189.33 -DnsOnly
Cannot bind parameter 'Type'. Unable to match the identifier name
HTTPS to a valid enumerator name. Specify one of the following:
UNKNOWN, A_AAAA, A, NS, MD, MF, CNAME, SOA, MB, MG, MR, NULL, WKS,
PTR, HINFO, MINFO, MX, TXT, RP, AFSDB, X25, ISDN, RT, AAAA, SRV,
DNAME, OPT, DS, RRSIG, NSEC, DNSKEY, DHCID, NSEC3, NSEC3PARAM,
ANY, ALL, WINSThe list ends at NSEC3PARAM, which is type 51. HTTPS is type 65 and SVCB is 64; neither is in the enum, and passing the number 65 is rejected the same way. The cmdlet cannot express the question.
nslookup fails more quietly, and the debug mode shows exactly how. It warns once, then asks a different question:
C:\> nslookup -d2 -type=65 dnsdoh.art 194.180.189.33
unknown query type: 65
...
SendRequest(), len 28
QUESTIONS:
dnsdoh.art, type = A, class = IN
...
SendRequest(), len 28
QUESTIONS:
dnsdoh.art, type = AAAA, class = IN
...
Name: dnsdoh.art
Address: 194.180.189.33It never sends type 65. It says unknown query type: 65, quietly substitutes the ordinary address lookups, and prints their result as though it were the answer. The two SendRequest() blocks are the proof: type = A and type = AAAA went on the wire, nothing else. -type=HTTPS behaves the same way.
The warning is one line above a screenful of output, and it is easy to lose: when we redirected the same command to a file, the warning did not appear in it and only the address did. Someone checking whether a site publishes an ECH key could reasonably read that as a result.
That is where this section first ended, with the advice to use a browser or install the BIND tools. That was the wrong conclusion: the cmdlet is the limitation, not the language. Build the query by hand and send it over a UDP socket, and the record comes back like any other. No installation, no dependencies, stock PowerShell.
# Fetch a DNS HTTPS record (type 65) on Windows, using nothing but PowerShell.
# Resolve-DnsName has no HTTPS type and nslookup substitutes A/AAAA, so the
# query is built by hand and sent over a raw UDP socket.
# 1. Build the raw DNS query for dnsdoh.art, type 65 (HTTPS)
$domainBytes = [System.Text.Encoding]::ASCII.GetBytes("dnsdoh")
$artBytes = [System.Text.Encoding]::ASCII.GetBytes("art")
$request = [System.Collections.Generic.List[byte]]::new()
# DNS header: ID, flags (standard query, recursion desired), 1 question
$header = [byte[]]@(0xAB, 0xCD, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
$request.AddRange($header)
# Question section: each label is a length byte followed by the label itself
$request.Add($domainBytes.Length); $request.AddRange($domainBytes)
$request.Add($artBytes.Length); $request.AddRange($artBytes)
# End of name (0x00), record type HTTPS (0x00 0x41 = 65), class IN (0x00 0x01)
$tail = [byte[]]@(0x00, 0x00, 0x41, 0x00, 0x01)
$request.AddRange($tail)
# 2. Send the UDP packet to the resolver on port 53
$udpClient = [System.Net.Sockets.UdpClient]::new()
$udpClient.Connect("194.180.189.33", 53)
[void]$udpClient.Send($request.ToArray(), $request.Count)
# 3. Read the reply
$remoteEP = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)
$response = $udpClient.Receive([ref]$remoteEP)
$udpClient.Close()
# 4. Hex-dump the whole answer so the HTTPS record structure is visible
Write-Host "`n--- RAW RESPONSE FROM THE RESOLVER ---" -ForegroundColor Green
for ($i = 0; $i -lt $response.Length; $i += 16) {
$chunk = $response[$i..($i + 15)] | Where-Object { $_ -ne $null }
$hex = ($chunk | ForEach-Object { "{0:x2}" -f $_ }) -join ' '
if ($chunk.Length -lt 16) { $hex = $hex.PadRight(47) }
$ascii = ($chunk | ForEach-Object { if ($_ -ge 32 -and $_ -le 126) { [char]$_ } else { '.' } }) -join ''
"{0:x8}: {1} {2}" -f $i, $hex, $ascii
}Which prints the answer the two built-in tools could not fetch:
--- RAW RESPONSE FROM THE RESOLVER ---
00000000: ab cd 81 80 00 01 00 01 00 00 00 00 06 64 6e 73 .............dns
00000010: 64 6f 68 03 61 72 74 00 00 41 00 01 c0 0c 00 41 doh.art..A.....A
00000020: 00 01 00 00 00 30 00 5d 00 01 00 00 01 00 0f 02 .....0.]........
00000030: 68 33 02 68 32 08 68 74 74 70 2f 31 2e 31 00 05 h3.h2.http/1.1..
00000040: 00 43 00 41 fe 0d 00 3d 02 00 20 00 20 49 28 fc .C.A...=.. . I(.
00000050: 04 4f f2 8b f1 84 0b a1 e5 5c e0 69 77 d3 a7 74 .O.......\.iw..t
00000060: bc ad 45 80 16 f8 96 5a 71 7a 4a d4 11 00 08 00 ..E....ZqzJ.....
00000070: 01 00 01 00 01 00 03 00 0a 64 6e 73 64 6f 68 2e .........dnsdoh.
00000080: 61 72 74 00 00 art..133 bytes, and it parses cleanly. ab cd is the transaction ID the script chose; 81 80 marks it a reply with recursion available; one question, one answer. 06 dnsdoh 03 art 00 is the name, then 00 41 - type 65, the question Windows could not previously ask. c0 0c in the answer is a compression pointer back to that name, the TTL is 00 00 00 30 or 48 seconds, and 00 5d announces 93 bytes of record data.
Those 93 bytes account for themselves exactly: priority 1, an empty target meaning “this same name”, then 00 01 with 15 bytes of ALPN list (h3, h2, http/1.1), then 00 05 with 67 bytes of ECH. Everything highlighted above is that ECH parameter, and it is byte for byte the same ECHConfig the base64 -d route produced earlier on this page - including dnsdoh.art, the public name, readable at the end. A hand-built packet from a Windows machine and a piped one-liner on Linux agreeing to the last byte is a better check on both than either alone.
One detail the raw view makes visible that dig hides: the additional-record count is zero. The script sends no EDNS record, which is the same thing Windows nslookup does, and the reason its byte counts differ from dig's default.
To check another name, change the two labels near the top; a three-label name needs a third pair of lines. The wider point is that the record was always ordinary DNS - any resolver will serve it, and the only thing in the way was the tooling.
What ECH still does not hide
ECH hides the name, not the address. Your browser still opens a connection to an IP, and the observer still sees that IP. Whether that gives the site away depends entirely on who else is behind it. On a large shared front-end where thousands of sites sit on the same address, the IP reveals almost nothing, the crowd is the protection. On a dedicated IP that hosts a single site, the IP alone still identifies the destination, and ECH buys you little on its own.
In other words, ECH is most effective when many sites share an address, and weakest when a site stands alone. It is a real improvement, not an invisibility cloak. What an IP does and does not reveal is covered in what your IP address reveals.
Where ECH stands today
Browser
Chrome and Firefox support ECH, switched on when encrypted DNS is in use. It is on by default in recent versions.
Server
The site must publish an ECH key in its DNS HTTPS record and support it at the TLS layer. Fewer sites do than you would guess - the survey above found three of eight names publishing a key, and cloudflare.com was not one of them.
Both needed
ECH only happens when browser and server both support it. If either side does not, the handshake falls back to plaintext SNI.
For most people there is nothing to configure: use an up-to-date browser with encrypted DNS, and ECH happens automatically on sites that support it. ECH protects the name in the handshake; post-quantum key exchange protects the session key behind it, so a recording cannot be decrypted later. On the server side, the ECH key this site publishes rotates monthly; how that key lives in the DNS HTTPS record is shown in Anatomy of a Hardened DNS Zone.
Close the first half of the gap
ECH needs encrypted DNS to mean anything. Start there: encrypt your lookups, then a modern browser handles the rest. To confirm a name actually publishes an ECH key, and that your own lookups are encrypted, see how to check each of those.
Encrypt your DNS