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:
# 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:
# /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:
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.
# 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:
# 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:
# 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:
// 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, 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
# 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:
# 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.