Networking
DNS over HTTPS (DoH)
Hiding your lookups in plain sight — DNS that looks like every other HTTPS request
DNS over HTTPS (DoH) tunnels your DNS queries inside an encrypted HTTPS connection (RFC 8484) so eavesdroppers on the network can't see which sites you visit or tamper with the answers — at the cost of a TLS handshake and centralized resolver trust.
- StandardRFC 8484 (Oct 2018)
- TransportHTTPS over port 443
- Endpoint/dns-query
- Media typeapplication/dns-message
- First-query cost+2–3 RTT (TLS)
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The postcard problem
Traditional DNS is a postcard. When you type unseel.com into a browser, your machine sends a tiny UDP packet to a resolver asking "what's the IP for this name?" — and that packet travels across the network in cleartext. Anyone in the path can read it: the coffee-shop router, your ISP, a state-run backbone. They learn every site you visit before you've sent a single byte of actual traffic. Worse, because the answer is also unauthenticated, an attacker can forge a reply and point you at a malicious server. This is the basis of DNS spoofing and was the engine behind the Kaminsky cache-poisoning attack disclosed in 2008.
DNS over HTTPS seals the postcard inside an envelope that looks exactly like every other envelope on the wire. Your DNS query is serialized into the ordinary binary DNS wire format, then sent as the body of an HTTPS request to a resolver that speaks DoH. Because it rides over TLS on port 443 — the same port and protocol as the rest of the web — an observer can't tell a DNS lookup apart from someone loading a web page. They see encrypted bytes to a server, nothing more.
How the query actually travels
RFC 8484, published in October 2018 by Hoffman and McManus, defines two ways to carry a DNS message over HTTPS. Both target a URI Template the resolver advertises, conventionally ending in /dns-query, and both use the media type application/dns-message — the raw DNS wire format from RFC 1035, byte-for-byte the same packet UDP would have carried.
- POST. The DNS query is the request body.
POST /dns-query HTTP/2withContent-Type: application/dns-messageand the binary message as the payload. Clean, but each request body is unique, which slightly hurts cacheability. - GET. The DNS query is base64url-encoded (unpadded) and placed in the
?dns=query parameter:GET /dns-query?dns=AAABAAABAAAAAAAAA3d3dwd.... This is friendlier to HTTP caches because identical queries produce identical URLs.
The full lifecycle of a fresh DoH lookup, in order:
- TCP handshake with the resolver (1 RTT) — DoH runs over a reliable stream, not a fire-and-forget UDP datagram.
- TLS handshake (1 RTT with TLS 1.3, 2 with TLS 1.2) — the client validates the resolver's certificate, defeating man-in-the-middle.
- HTTP/2 request carrying the DNS query in wire format.
- Response — an HTTP 200 whose body is the DNS answer message, including a
Cache-Control: max-ageheader that the resolver derives from the record's TTL so HTTP caches respect DNS semantics. - Connection reuse — the same TLS connection is kept alive and multiplexed, so every later lookup is a single round trip, just like UDP DNS, with none of the per-query handshake cost.
The encryption itself is plain TLS — there is no DNS-specific crypto. DoH inherits exactly the confidentiality and integrity guarantees of HTTPS: an on-path observer sees the resolver's IP and the total number of encrypted bytes, but not which names you resolved or what answers came back.
When DoH helps — and when it hurts
- Hostile or untrusted networks. Public Wi-Fi, hotels, airports — anywhere the local router could log or rewrite your DNS. DoH is the single biggest win here.
- ISP surveillance and monetization. Many ISPs log DNS, sell the data, or inject ads via NXDOMAIN hijacking. DoH to an independent resolver cuts them out of the loop.
- Censorship circumvention. DNS-based blocking (the cheapest censorship technique) fails when the censor can't see or selectively drop your queries.
But DoH is the wrong default in some contexts. On a corporate network it can break split-horizon DNS, where internal names like intranet.corp only resolve through the company's resolver. It defeats DNS-based content filtering used by schools and parental controls. And it concentrates trust: you stop trusting your ISP and start trusting whichever public resolver you chose — Cloudflare, Google, or Quad9 — which now sees every domain you look up. Privacy isn't created; it's relocated.
DoH vs the alternatives
| Plain DNS (Do53) | DNS over TLS (DoT) | DNS over HTTPS (DoH) | DNSCrypt | Oblivious DoH | |
|---|---|---|---|---|---|
| Standard | RFC 1035 (1987) | RFC 7858 (2016) | RFC 8484 (2018) | de facto (OpenDNS) | RFC 9230 (2022) |
| Transport / port | UDP/TCP 53 | TLS 853 | HTTPS 443 | UDP/TCP 443/443 | HTTPS 443 via relay |
| Encrypted | No | Yes | Yes | Yes | Yes |
| Blends with web traffic | No | No (port 853 stands out) | Yes | No | Yes |
| Resolver sees your IP + query | Yes | Yes | Yes | Yes | No (split) |
| Network admin can selectively block | Easy | Easy | Hard | Medium | Hard |
| Per-query overhead (warm) | ~0 | ~0 | ~0 | low | 1 extra hop |
The headline distinction is DoH vs DoT. They give the same cryptographic privacy from on-path observers. The difference is visibility: DoT runs on a dedicated port that a firewall can simply block, while DoH hides inside the ocean of HTTPS, indistinguishable from a normal page load. That's a feature if you're a user evading surveillance and a bug if you're an administrator trying to enforce policy.
What the numbers actually say
- Cold-start latency: typically +50–150 ms. A fresh DoH lookup pays for a TCP handshake (1 RTT) plus a TLS handshake (1 RTT on TLS 1.3, 2 on TLS 1.2). On a 30 ms RTT link that's 60–90 ms of extra setup before the first answer; plain UDP DNS would have returned in a single 30 ms round trip.
- Warm-state latency: ≈ 0 added. Once the connection is established and reused (HTTP keep-alive plus HTTP/2 multiplexing), each subsequent query is one round trip — the same as Do53. TLS 1.3 0-RTT resumption can even let a reconnecting client send a query in the very first packet.
- Bytes on the wire: roughly 5–10× a UDP query for setup. A bare DNS query is ~30–60 bytes; the TLS + HTTP/2 framing and headers add hundreds of bytes once. Amortized over a kept-alive connection, the per-query overhead drops to a handful of HTTP/2 header bytes thanks to HPACK compression.
- Adoption. Firefox began rolling DoH on by default for US users in February 2020; Chrome shipped "Secure DNS" the same year. Cloudflare's 1.1.1.1, Google's 8.8.8.8, and Quad9's 9.9.9.9 all expose public DoH endpoints at
/dns-query.
JavaScript: a DoH client in the browser
Modern resolvers also expose a JSON API (Google and Cloudflare both do), which is the easiest way to query DoH from JavaScript without hand-encoding the binary wire format. The canonical RFC 8484 path uses application/dns-message; the JSON variant uses application/dns-json.
// JSON DoH — simple, human-readable, supported by Cloudflare & Google
async function dohJson(name, type = 'A') {
const url = new URL('https://cloudflare-dns.com/dns-query');
url.searchParams.set('name', name);
url.searchParams.set('type', type);
const res = await fetch(url, {
headers: { 'Accept': 'application/dns-json' }
});
if (!res.ok) throw new Error(`DoH HTTP ${res.status}`);
const data = await res.json();
// data.Status: 0 = NOERROR (RFC 1035 RCODE)
if (data.Status !== 0) throw new Error(`DNS RCODE ${data.Status}`);
return (data.Answer || [])
.filter(a => a.type === 1) // type 1 = A record
.map(a => ({ ip: a.data, ttl: a.TTL }));
}
// Wire-format DoH (RFC 8484) — base64url-encode the binary query into ?dns=
function buildQuery(name) {
const labels = name.split('.');
const body = [];
for (const l of labels) {
body.push(l.length, ...[...l].map(c => c.charCodeAt(0)));
}
body.push(0); // root label terminator
body.push(0x00, 0x01); // QTYPE = A
body.push(0x00, 0x01); // QCLASS = IN
const header = [
0x00, 0x00, // ID (0 — HTTPS handles dedup)
0x01, 0x00, // flags: RD (recursion desired)
0x00, 0x01, // QDCOUNT = 1
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
];
const msg = Uint8Array.from([...header, ...body]);
// base64url, unpadded — per RFC 8484 §4.1
return btoa(String.fromCharCode(...msg))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function dohWire(name) {
const dns = buildQuery(name);
const res = await fetch(`https://cloudflare-dns.com/dns-query?dns=${dns}`, {
headers: { 'Accept': 'application/dns-message' }
});
return new Uint8Array(await res.arrayBuffer()); // raw DNS answer, needs parsing
}
The JSON path is what you reach for in practice; the wire-format builder is here to show that a DoH query is just a standard RFC 1035 packet base64url-encoded into a URL — there is nothing mysterious about the "encryption," because TLS does all of it.
Python: query and decode a DoH response
import base64
import httpx # HTTP/2-capable client
import dns.message # dnspython, for wire-format encode/decode
DOH_URL = "https://1.1.1.1/dns-query"
def doh_query(name: str, rdtype: str = "A"):
# Build a standard DNS query message (RFC 1035 wire format)
q = dns.message.make_query(name, rdtype)
wire = q.to_wire()
# GET variant: base64url, unpadded, in the ?dns= parameter (RFC 8484 §4.1)
b64 = base64.urlsafe_b64encode(wire).rstrip(b"=").decode()
# http2=True lets one TLS connection multiplex many lookups
with httpx.Client(http2=True) as client:
r = client.get(
DOH_URL,
params={"dns": b64},
headers={"accept": "application/dns-message"},
)
r.raise_for_status()
ans = dns.message.from_wire(r.content) # decode the DNS answer
return [rr.to_text() for rrset in ans.answer for rr in rrset]
# POST variant — body IS the wire-format query, nothing in the URL
def doh_query_post(name: str, rdtype: str = "A"):
wire = dns.message.make_query(name, rdtype).to_wire()
with httpx.Client(http2=True) as client:
r = client.post(
DOH_URL,
content=wire,
headers={"content-type": "application/dns-message",
"accept": "application/dns-message"},
)
r.raise_for_status()
return dns.message.from_wire(r.content).answer
if __name__ == "__main__":
print(doh_query("unseel.com")) # -> ['unseel.com. 300 IN A 104.x.x.x', ...]
Note the two transport choices map directly onto HTTP verbs: GET puts the base64url query in the URL (cache-friendly, identical queries hit the same cache key), POST puts the raw bytes in the body (no encoding step, but each request body is opaque to caches). The DNS message itself is untouched in either case.
Variants worth knowing
DNS over TLS (DoT), RFC 7858. Same encryption, dedicated port 853. Easier for a network operator to allow or block deliberately, which is exactly why enterprises tend to prefer it and privacy advocates prefer DoH.
DNS over QUIC (DoQ), RFC 9250. Carries DNS over QUIC's UDP-based encrypted transport, avoiding TCP head-of-line blocking and cutting handshake latency with QUIC's 0-RTT. Effectively DoT's spiritual successor on a faster pipe.
Oblivious DoH (ODoH), RFC 9230. Solves DoH's biggest residual leak — the resolver still sees your IP and your queries. ODoH double-encrypts the query and routes it through a relay: the relay learns your IP but not the query (it's encrypted to the resolver's public key), and the resolver learns the query but not your IP (it only sees the relay). Privacy holds as long as relay and resolver don't collude.
Encrypted Client Hello (ECH). Complementary, not a DoH variant. DoH hides the DNS lookup, but the subsequent TLS handshake to the website historically leaked the hostname in the cleartext SNI field. ECH encrypts SNI too — and it bootstraps off DNS (it fetches the public key from an HTTPS record), so DoH and ECH are designed to work together.
DDR — Discovery of Designated Resolvers (RFC 9462). Lets a client automatically upgrade from the network's plain Do53 resolver to that same operator's DoH endpoint, so you get encryption without overriding the network's chosen resolver — the compromise that keeps split-horizon DNS working.
Common bugs and edge cases
- The bootstrap chicken-and-egg. To reach
cloudflare-dns.comyou need to resolve its name — but DNS is the thing you're trying to encrypt. Real clients hardcode the resolver's IP (1.1.1.1, 8.8.8.8) or pin its certificate so the first connection doesn't depend on the very system it replaces. - Forgetting unpadded base64url. RFC 8484 §4.1 requires base64url (
-and_instead of+and/) with no=padding. Standard base64 or leftover padding makes the resolver reject the query. - Ignoring the response TTL for caching. A correct DoH client honors the answer's TTL (surfaced via the HTTP
Cache-Control: max-ageheader) rather than the HTTP cache defaults; otherwise you either re-query needlessly or serve stale records. - Not reusing the connection. Opening a fresh TLS connection per query throws away DoH's whole performance story — every lookup pays the full handshake. Always keep the HTTP/2 connection alive and multiplex queries over it.
- Breaking split-horizon and captive portals. Forcing DoH on a corporate or hotel network can make internal names unresolvable and break captive-portal sign-in pages, which depend on intercepting plain DNS. Clients implement "canary domain" checks and DDR to fall back gracefully.
- Assuming DoH equals anonymity. It encrypts the lookup, not your identity. The resolver still logs your IP against your queries unless you use ODoH, and the destination IP of the connection you open next is plainly visible regardless.
Frequently asked questions
What's the difference between DoH and DoT?
Both encrypt DNS, but DoH (RFC 8484) wraps queries in normal HTTPS on port 443 so they blend into ordinary web traffic, while DoT (RFC 7858) runs DNS over a dedicated TLS connection on port 853. Because port 853 is visible and easy to block, DoH is harder for a network to single out — at the cost of being harder for a network administrator to monitor for legitimate reasons.
Does DoH hide the website I'm visiting?
It hides the DNS lookup from anyone on the network path, but not from your resolver, and usually not from your ISP. The TLS handshake to the site itself historically leaked the hostname in the unencrypted SNI field, and even with Encrypted Client Hello the destination IP address is still visible. DoH closes the DNS leak, not every leak.
How much slower is DoH than plain DNS?
The first query pays for a TCP handshake plus a TLS handshake — roughly 2 to 3 extra round trips, often 50 to 150 ms. After that the connection is reused (HTTP keep-alive), so subsequent lookups cost about the same as plain DNS. TLS 1.3, 0-RTT resumption, and HTTP/2 multiplexing shrink the steady-state penalty to near zero.
Why do some network admins hate DoH?
Because it bypasses network-level DNS controls. Schools, enterprises, and parental-control products filter unwanted domains by inspecting or intercepting DNS. When a browser silently switches to DoH against a public resolver, those filters go blind, and the encrypted queries look identical to ordinary HTTPS. The same property that protects users from surveillance defeats legitimate policy enforcement.
What is Oblivious DoH (ODoH)?
Plain DoH still lets the resolver see both your IP address and your queries, so it knows who is asking for what. Oblivious DoH (RFC 9230) inserts a relay between you and the resolver: the relay sees your IP but not the encrypted query, and the resolver sees the query but not your IP. As long as the two don't collude, no single party links identity to lookups.
Should I just turn DoH on everywhere?
For privacy against café Wi-Fi snoops and ISP logging, yes — but understand the trade. You shift trust from your ISP to whichever resolver you pick (Cloudflare 1.1.1.1, Google 8.8.8.8, Quad9), centralizing visibility into one operator. On a managed corporate or school network, DoH may break split-horizon DNS and content filtering, so the right answer is often to use the network's own DoH resolver rather than a public one.