# DNS-over-HTTPS

**Plain UDP/53 is unencrypted, unauthenticated, and blocked outright by half the networks an agent runs on — CI sandboxes, corporate egress proxies, serverless containers with no raw sockets.**

DoH turns a DNS query into an HTTPS request, so if an agent can reach the web at all, it can resolve through Whisper's policy-aware, graph-first resolver — every answer wrapped in TLS instead of sitting in the clear for anyone on the path to read or spoof.

That matters more for agents than it ever did for browsers. An agent spawned in a container often has exactly one open path out — port 443 to a fixed set of hosts — and no `/etc/resolv.conf` control worth trusting. DNS-over-HTTPS (DoH), specified in [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484), lets that agent send a real DNS message over the same HTTPS stack it already uses for everything else, and get back a real DNS message, confidentiality- and integrity-protected end to end.

## The wire format, precisely

RFC 8484 defines DoH as HTTP carrying the standard DNS message format from [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035) — not a redesigned protocol, just DNS's existing 12-byte header + question + RR sections, transported over HTTPS instead of UDP/53. Two request shapes are defined:

**POST** — the query is the literal, unmodified DNS wire-format message, sent as the request body:

```
:method = POST
:scheme = https
:authority = doh.whisper.online
:path = /dns-query
accept = application/dns-message
content-type = application/dns-message
content-length = <length of the DNS message>

<the DNS message in wire format, verbatim>
```

**GET** — the same wire-format message, base64url-encoded (RFC 4648 §5, no padding) into a single `dns` query parameter:

```
:method = GET
:scheme = https
:authority = doh.whisper.online
:path = /dns-query?dns=AAABAAABAAAAAAAAA2FjZQVhZ2VudA...
accept = application/dns-message
```

GET is what makes DoH cacheable by ordinary HTTP infrastructure (the response's `Cache-Control` / TTL-derived `max-age` behaves exactly like any other HTTP resource) — a shared HTTP cache in front of a resolver can shave real load off hot names without any DNS-specific logic. POST has no such caching but avoids URL-length limits on huge messages (large DNSSEC RRsets, many-name queries). Both forms are answered with `content-type: application/dns-message` — a standard DNS response, RCODE and all, byte-identical to what you'd get on UDP/53 except it arrived over TLS 1.2+.

There is also a JSON form some resolvers offer (`application/dns-json`, non-standardized, originally a Google/Cloudflare convenience format) for `curl`-and-`jq` debugging without a hex editor. Whisper's resolver speaks the RFC 8484 wire format; use the wire form for anything that has to be correct, and reach for a JSON-speaking public resolver only for throwaway shell debugging.

## Why the mechanism, not just the transport

DoH is not "DNS but encrypted" as an afterthought — it's DNS **as an HTTP resource**, which is what makes it survive networks that plain DNS can't cross:

- It rides port 443, the one port that is almost never firewalled, proxied-away, or MITM'd by a corporate TLS-inspecting proxy without the proxy's own CA being trusted client-side (which would break every other HTTPS call too, so it's rarely done to DoH specifically).
- The DNS message is opaque inside the TLS record — an on-path observer sees an HTTPS connection to `doh.whisper.online`, not which names were queried. `DNS0x20` and QNAME case aren't visible to anyone but the two TLS endpoints.
- Because it's a normal HTTP request, it carries normal HTTP headers — which is exactly the seam Whisper uses to attach a per-tenant API key (`X-API-Key`) to a DNS query. Plain UDP/53 has no header field for that; DoH does.

## The Whisper resolver over DoH

Point an agent's DoH client at `https://doh.whisper.online/dns-query` with its `X-API-Key`, and every query runs through the same [graph-first policy resolution](/docs/resolver) whisper-ns applies on :53: the name is checked against the live security graph — geography, category, ownership, listing status — before an answer is returned; a graph opinion drives the response, and a clean name falls through to upstream DNS unmodified. **Fail-open, always** — if the graph is unreachable, resolution proceeds as plain, correct DNS rather than hanging or erroring, per [the robustness principle](/docs/architecture) this whole system is built on.

DoH is one of two ways to reach that same policy resolver — pick per your network:

| | Transport | Auth | Best for |
|---|---|---|---|
| **DoH** | HTTPS/443, `application/dns-message` | `X-API-Key` header | egress-restricted networks, browsers, anything that can already do HTTPS |
| **Dedicated `:53`** | plain UDP/TCP 53 | the resolver IP itself (a per-tenant `/128`) | agents with open UDP egress that want zero per-query overhead |

Both are Tier 2 in the [connectivity tiers](/docs/connect) — "keep your own address, use Whisper for policy + control" — as opposed to Tier 1/1.5, where the agent's traffic actually egresses *from* a routable Whisper `/128`.

## With stock tools

No Whisper software required — any HTTP client that can build a DNS message works. Because the Whisper resolver **authenticates every DoH request** — it is never an open resolver — each example carries your tenant token. The token is **polymorphic in how it's presented**; pick whichever channel your client can do:

- **`X-API-Key: <token>`** header (or `Authorization: Bearer <token>`) — the default for anything that sets headers.
- **path-embedded** — `https://doh.whisper.online/<token>/dns-query` — for clients (like `kdig`) that can't add a header.
- **host subdomain** — `<token>.doh.whisper.online` — a per-tenant hostname.

Presenting the *same* token on two channels is fine; presenting two *different* tokens is rejected outright (never silently resolved with one).

`kdig` (from `knot-dnsutils`) speaks DoH natively; give it the token in the path, since `kdig` sets no custom HTTP header:

```bash
# kdig native DoH (+https=<path>) — token rides the URL path
$ kdig +https=/whisper_live_.../dns-query @doh.whisper.online \
       acef2002a323d40d4.demo.agents.whisper.online AAAA

;; DoH session (HTTP/2 https://doh.whisper.online:443/whisper_live_.../dns-query)
;; ANSWER SECTION:
acef2002a323d40d4.demo.agents.whisper.online. 300 IN AAAA 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
```

Or build the raw wire-format message yourself and POST it with `curl` — the fully manual path, useful for verifying exactly what's on the wire. You don't even need a DNS library: a plain AAAA query is just the 12-byte header + one question, so a few lines of `python3` stdlib hand-assemble it:

```bash
# Hand-build a minimal wire-format AAAA query (id, flags, 1 question, no EDNS)
$ python3 - <<'EOF' > query.bin
import struct
name = "acef2002a323d40d4.demo.agents.whisper.online"
qname = b"".join(struct.pack("B", len(l)) + l.encode() for l in name.split(".")) + b"\x00"
header = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)  # id, RD=1, QDCOUNT=1
question = qname + struct.pack(">HH", 28, 1)                  # QTYPE=AAAA(28), QCLASS=IN(1)
import sys; sys.stdout.buffer.write(header + question)
EOF

$ curl -s -X POST https://doh.whisper.online/dns-query \
       -H 'content-type: application/dns-message' \
       -H 'accept: application/dns-message' \
       -H 'X-API-Key: whisper_live_...' \
       --data-binary @query.bin -o answer.bin

$ xxd answer.bin | head -4   # RCODE is the low nibble of byte 3; ANCOUNT is bytes 6-7
```

GET form, base64url-encoding the same query with plain shell tools — here presenting the token in the **path** instead of a header (both authenticate identically):

```bash
$ QUERY_B64=$(base64 -w0 query.bin | tr '+/' '-_' | tr -d '=')
$ curl -s "https://doh.whisper.online/whisper_live_.../dns-query?dns=${QUERY_B64}" \
       -H 'accept: application/dns-message' -o answer.bin
```

## With Whisper

The `whisper` CLI provisions an agent and hands you a key already scoped to the resolver — one command, the key shown once:

```bash
$ whisper create --register --name my-agent
whisper: created my-agent — 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
api_key: whisper_live_...        # shown once — this is your DoH credential
```

Under the hood that's a single control-plane call — `graph.whisper.security` is the same Cypher-shaped control API the CLI itself speaks:

```cypher
CALL whisper.agents({op:'register', args:{label:'my-agent'}})
-> { api_key: "whisper_live_...",
     agent:   "agent-acef2002a323d40d4",
     address: "2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4",
     fqdn:    "acef2002a323d40d4.demo.agents.whisper.online",
     ptr:     "acef2002a323d40d4.demo.agents.whisper.online" }
```

The DoH endpoint itself is a single constant — `https://doh.whisper.online/dns-query` — so there is nothing to allocate per agent: present the returned `api_key` (header, path, or host-subdomain, above) to any HTTPS-capable DoH client and you're resolving through the policy engine:

```bash
$ curl -s "https://doh.whisper.online/dns-query?dns=${QUERY_B64}" \
       -H 'accept: application/dns-message' \
       -H 'X-API-Key: whisper_live_...' -o answer.bin
```

No allowlisting a new IP, no opening UDP/53, because it's a hostname on 443 your egress already trusts. See [Connect & egress](/docs/connect) for the full tier comparison and the WireGuard/SOCKS5 tiers that go further than "just resolve" into "actually route as the agent's own identity."

## Next

Read [Graph-first resolution](/docs/resolver) for what happens after the DoH message lands — the policy engine that decides an answer before falling through to upstream DNS — and [Connect & egress](/docs/connect) for how DoH fits alongside WireGuard and SOCKS5 as one of the four ways an agent reaches the Whisper network.
