<!-- Canonical: https://lucioduran.com/blog/dns-over-https-encrypted-client-hello-privacy
Language: en
Content-SHA256: 6ea02bc2cf430d228ec54e69b1885bc109974f1dbe06d8a6bbf4ee81d8512185
-->

Networking

# DNS Privacy Stack: DoH, DoT, DNSSEC, Encrypted Client Hello, and Comparative Privacy Analysis

2026-01-087 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/dns-over-https-encrypted-client-hello-privacy>)

## The Metadata Leak Problem

When you visit ` https://secret-stuff.example.com `, your HTTPS traffic is encrypted. But the connection process leaks metadata at multiple points:

```
Step 1: DNS Resolution
 Client → ISP DNS: "What's the IP of secret-stuff.example.com?"
 ISP DNS → Client: "It's 203.0.113.42"
 [LEAKED: The domain you're visiting]

Step 2: TCP Connection
 Client → 203.0.113.42: SYN
 [LEAKED: The IP you're connecting to]

Step 3: TLS Handshake
 Client → Server: ClientHello { SNI: "secret-stuff.example.com" }
 [LEAKED: The domain again, in cleartext]

Step 4: Encrypted Application Data
 [PROTECTED: The actual HTTP request and response]
```

Three out of four steps leak the domain you're visiting. Encrypted DNS only fixes step 1. ECH fixes step 3. Step 2 is fundamentally hard (requires a proxy, VPN, or Tor).

## DNS-over-HTTPS (DoH): The Basics

DoH wraps DNS queries in standard HTTPS requests, using ` application/dns-message ` content type:

```bash
# DoH query using curl (wire format)
curl -s -H 'Accept: application/dns-message' \
 'https://cloudflare-dns.com/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
 | python3 -c "import sys; data=sys.stdin.buffer.read(); print(data.hex())"

# DoH query using JSON format (Cloudflare/Google extension)
curl -s -H 'Accept: application/dns-json' \
 'https://cloudflare-dns.com/dns-query?name=example.com&type=A' | jq .

# Response:
{
 "Status": 0,
 "TC": false,
 "RD": true,
 "RA": true,
 "AD": true,
 "CD": false,
 "Question": [{"name": "example.com", "type": 1}],
 "Answer": [
 {"name": "example.com", "type": 1, "TTL": 3600,
 "data": "93.184.216.34"}
 ]
}
```

Setting up a local DoH resolver with [` dnscrypt-proxy `](<https://github.com/DNSCrypt/dnscrypt-proxy>):

```toml
# /etc/dnscrypt-proxy/dnscrypt-proxy.toml
listen_addresses = ['127.0.0.1:53', '[::1]:53']
server_names = ['cloudflare', 'cloudflare-ipv6']
doh_servers = true
require_dnssec = true
require_nofilter = true
require_nolog = true

# Connection settings
timeout = 5000
keepalive = 30
cert_refresh_delay = 240

# Caching
cache = true
cache_size = 4096
cache_min_ttl = 2400
cache_max_ttl = 86400
cache_neg_min_ttl = 60
cache_neg_max_ttl = 600

# Oblivious DoH relays
[anonymized_dns]
routes = [
 { server_name='cloudflare', via=['anon-relay-1', 'anon-relay-2'] }
]
```

## DNS-over-TLS (DoT): The Enterprise Alternative

DoT uses TLS directly on port 853, without the HTTP layer:

```python
import ssl
import socket
import struct

def dot_query(domain, resolver='1.1.1.1', port=853):
 # Build DNS query
 query = build_dns_query(domain, qtype='A')

 # Wrap with 2-byte length prefix (TCP DNS framing)
 framed = struct.pack('!H', len(query)) + query

 # TLS connection
 ctx = ssl.create_default_context()
 ctx.check_hostname = True
 ctx.verify_mode = ssl.CERT_REQUIRED

 with socket.create_connection((resolver, port)) as sock:
 with ctx.wrap_socket(sock, server_hostname='cloudflare-dns.com') as tls:
 tls.sendall(framed)

 # Read response length
 length_data = tls.recv(2)
 length = struct.unpack('!H', length_data)[0]

 # Read response
 response = tls.recv(length)
 return parse_dns_response(response)
```

DoT vs DoH trade-offs:

```
 DoH (port 443) DoT (port 853)
Indistinguishable Yes (looks like No (dedicated port)
from HTTPS traffic normal browsing)
Blockable by FW Hard (blocks all Easy (block port 853)
 HTTPS)
Enterprise control Hard to manage Easy to detect/redirect
Performance HTTP/2 multiplexing Dedicated TLS, less
 with other traffic overhead per query
Padding support Via HTTP/2 frames Via EDNS0 padding
```

## DNSSEC: Integrity, Not Confidentiality

DNSSEC adds cryptographic signatures to DNS records. It does NOT encrypt anything, it only proves the response wasn't modified in transit.

```bash
# Check DNSSEC validation with dig
dig +dnssec example.com

# Look for:
# - 'ad' flag in header (Authenticated Data)
# - RRSIG records (signatures)
# - DNSKEY records (zone signing keys)

# Verify the chain of trust manually
dig +trace +dnssec example.com

# Chain: root (.) → .com → example.com
# Each level signs the delegation to the next
# The root zone's key is the trust anchor (built into resolvers)
```

DNSSEC validation on your local resolver:

```bash
# Using unbound as a validating resolver
# /etc/unbound/unbound.conf
server:
 interface: 127.0.0.1
 port: 5353
 do-not-query-localhost: no

 # DNSSEC validation
 auto-trust-anchor-file: "/var/lib/unbound/root.key"
 val-clean-additional: yes
 val-permissive-mode: no # Strict: fail on DNSSEC errors
 harden-dnssec-stripped: yes

 # Forward to DoH resolver
 forward-zone:
 name: "."
 forward-tls-upstream: yes
 forward-addr: 1.1.1.1@853#cloudflare-dns.com
 forward-addr: 1.0.0.1@853#cloudflare-dns.com
```

## Encrypted Client Hello (ECH): The Missing Piece

ECH encrypts the entire ClientHello (including SNI) using a public key published in the DNS record of the server. Here's how it works:

```
Without ECH:
 Client → Server: ClientHello {
 SNI: "secret-stuff.example.com" ← VISIBLE to on-path observer
 supported_ciphers: [...]
 supported_groups: [...]
 }

With ECH:
 1. Client fetches HTTPS DNS record for secret-stuff.example.com
 → Contains ECH config with public key

 2. Client → Server: ClientHello {
 SNI: "cloudflare-ech.com" ← "Outer" SNI (public-facing domain)
 encrypted_client_hello: {
 // Encrypted with server's ECH public key:
 inner_SNI: "secret-stuff.example.com"
 supported_ciphers: [...]
 }
 }

 Observer sees: connection to "cloudflare-ech.com"
 Server decrypts: actual destination is "secret-stuff.example.com"
```

The DNS record looks like:

```bash
# Fetch ECH configuration via DNS HTTPS record
dig +short HTTPS cloudflare.com
# 1 . alpn="h3,h2" ipv4hint=104.16.132.229 ech=AEX+...

# The ech= parameter contains:
# - KEM (Key Encapsulation Mechanism) ID
# - Public key for encrypting the inner ClientHello
# - Symmetric cipher suite
# - Maximum name length
```

Testing ECH in your browser:

```javascript
// Check if ECH is active on a connection
// In Firefox: about:networking#dns
// Look for "hasECH: true" in the connection info

// Programmatic check (from web page):
// Currently no standard API, but you can test against
// defo.ie/ech-check.php or similar test servers

// Firefox about:config settings:
// network.dns.echconfig.enabled = true
// network.dns.use_https_rr_as_altsvc = true
```

## Oblivious DoH (ODoH): Trust Nobody

Standard DoH has a trust problem: you're replacing your ISP as the entity that sees your DNS queries with your DoH resolver ([Cloudflare](<https://cloudflare.com/>), Google, etc.). ODoH fixes this:

```
Standard DoH:
 Client →→ Resolver
 Resolver sees: client IP + query
 Problem: resolver knows everything

Oblivious DoH:
 Client → Relay → Resolver
 Relay sees: client IP, but query is encrypted to resolver
 Resolver sees: query content, but not client IP (sees relay IP)
 Nobody sees both!
```

The protocol works like this:

```
1. Client fetches resolver's ODoH target key (via DNS)
2. Client encrypts DNS query with resolver's public key
3. Client sends encrypted query to relay (standard HTTPS POST)
4. Relay forwards encrypted blob to resolver (can't read it)
5. Resolver decrypts, resolves, encrypts response
6. Response flows back: resolver → relay → client
```

```bash
# Configure ODoH with dnscrypt-proxy
[anonymized_dns]
routes = [
 { server_name = 'cloudflare', via = ['odoh-relay-crypto-sx'] },
 { server_name = 'cloudflare', via = ['odoh-relay-amsterdam'] }
]

# Performance comparison:
# Standard DNS (UDP): ~5 ms
# DoH (direct): ~25 ms
# DoT (direct): ~20 ms
# ODoH (via relay): ~35-40 ms
# Double-relay ODoH: ~55-70 ms
```

## The Complete Privacy Stack

Here is a recommended complete privacy stack configuration:

```bash
# Stack:
# 1. dnscrypt-proxy (local port 53) → ODoH to Cloudflare via relay
# 2. DNSSEC validation enabled
# 3. Firefox with ECH enabled
# 4. HTTPS-only mode

# /etc/dnscrypt-proxy/dnscrypt-proxy.toml
listen_addresses = ['127.0.0.1:53']
server_names = ['cloudflare']
doh_servers = true
require_dnssec = true

[anonymized_dns]
routes = [
 { server_name='cloudflare', via=['anon-relay-nl'] }
]

# Firefox about:config
network.dns.echconfig.enabled = true
network.dns.use_https_rr_as_altsvc = true
network.trr.mode = 3 # DoH only, no fallback to system DNS
network.trr.uri = https://127.0.0.1:3000/dns-query # Local proxy
dom.security.https_only_mode = true
```

## Honest Privacy Analysis

Here is an honest assessment of what each layer actually provides:

| Threat | Plain DNS | DoH/DoT | + DNSSEC | + ECH | + ODoH |
| --- | --- | --- | --- | --- | --- |
| ISP sees domains | Yes | **No** | No | No | No |
| Café WiFi sniffing | Yes | **No** | No | No | No |
| DNS spoofing | Yes | No | **No** | No | No |
| Resolver sees queries | Yes | Yes | Yes | Yes | **No** |
| SNI leaks domain | Yes | Yes | Yes | **No** | No |
| Destination IP visible | Yes | Yes | Yes | Yes | Yes |

That last row is the elephant in the room. Even with the full stack, anyone who can see your network traffic knows the IP address you're connecting to. For shared hosting (like Cloudflare), the IP maps to millions of sites, so it's not very revealing. For sites on dedicated IPs, the destination IP alone identifies the site.

For the last remaining leak, you need a proxy, VPN, or Tor. There's no DNS-level solution for it.

The privacy stack described above is about raising the floor, making passive surveillance significantly harder without dramatically impacting performance. It's not about achieving perfect anonymity. If you need that, you need Tor. But for everyday privacy against ISP monitoring, corporate network inspection, and café WiFi sniffing, DoH + ECH + ODoH gets you about 90% of the way there with under 50ms of added latency.

## Frequently asked questions

### What's the difference between DNS-over-HTTPS (DoH) and DNS-over-TLS (DoT)?

DoH encapsulates DNS queries in HTTPS requests on port 443, making them indistinguishable from regular HTTPS traffic. DoT uses a dedicated port (853) with TLS, which makes it easy for network administrators to identify and block. DoH is harder to block but also harder to manage in enterprise environments.

### Does Encrypted Client Hello (ECH) replace the need for a VPN?

ECH encrypts the SNI field in TLS handshakes, preventing on-path observers from seeing which domain you're connecting to. Combined with DoH, it covers the two main metadata leaks in HTTPS. However, a VPN also hides your destination IP, which ECH doesn't. For comprehensive privacy, you still need either a VPN or Tor.

### What is Oblivious DoH and when should I use it?

Oblivious DoH (ODoH) adds a relay between the client and resolver, so the resolver never sees the client's IP and the relay never sees the DNS query content. Use it when you need DNS privacy from both your network path AND your DNS resolver. The latency overhead is typically 5-15ms per query.

## Tips

1. **Tip:** DoH protects DNS queries from your ISP, but your resolver (Cloudflare, Google) still sees everything. If you need real DNS privacy, run ` Oblivious DoH ` through a relay, neither the relay nor the resolver can link your IP to your queries.
2. **Tip:** Encrypted Client Hello (ECH) is the missing piece that DoH alone cannot solve. Without ECH, the SNI in TLS ClientHello still leaks which domain you're connecting to, even if your DNS queries are encrypted. Check your browser's ` about:config ` for ` network.dns.echconfig.enabled `.
3. **Tip:** DNSSEC validates that DNS records haven't been tampered with, but it does NOT encrypt anything, your queries and responses are still visible to on-path observers. You need both DNSSEC (integrity) and DoH/DoT (confidentiality) for complete protection.

- dns
- privacy
- doh
- dot
- dnssec
- ech
- encrypted-client-hello
- networking
- security

[Back to Blog](<https://lucioduran.com/blog>)
