One Domain, Different Answers: Reading Cloudflare and Google DoH JSON
A colleague says a site loads on one network and fails on another. dig +short on your machine returns one address; a public resolver returns another. The same happens between two public resolvers: they can return differ
A colleague says a site loads on one network and fails on another. dig +short on your machine returns one address; a public resolver returns another.
The same happens between two public resolvers: they can return different address sets for one name, and the JSON shows which mechanism produced the difference. The numbers below were measured on 2026-09-30 and 2026-11-03 with the ZeroTool DNS Lookup; each applies to that date and those inputs.
Both endpoints speak the same schema
Cloudflare and Google expose a JSON profile of DNS over HTTPS: a GET with ?name= and ?type=. The binary form is RFC 8484; the JSON profile has no RFC. Cloudflare's documentation says it follows Google's schema, so the field names line up: Status (the RCODE), AD, Answer, Comment for resolver diagnostics. Google adds extended_dns_errors.
The JSON profile works from a page because a cross-origin GET is enough. On 2026-11-03 both endpoints returned access-control-allow-origin: *. The tool sends Accept: application/dns-json, a CORS-safelisted header, so no preflight. A POST with a custom Content-Type triggers one.
ECS explains most of the A-record differences
Google Public DNS normally sends approximate network information to authoritative servers, usually zeroing out the last part of the IPv4 address. edns_client_subnet=0.0.0.0/0 disables that. Cloudflare's FAQ says 1.1.1.1 does not send ECS. A CDN that answers per subnet therefore hands the two resolvers different addresses.
For www.qq.com on 2026-09-30:
edns_client_subnet |
Google answer |
|---|---|
114.114.114.0/24 |
121.14.77.201, 121.14.77.221
|
8.8.8.0/24 |
43.159.109.55 |
139.130.4.0/24 |
43.168.224.173 |
Cloudflare ignored the same parameter and returned 43.159.109.55, matching Google's 8.8.8.0/24 answer in that run. That is one name on one day, not a rule. Comparing resolvers means comparing vantage points: Google's answer moves with the subnet you name, Cloudflare's does not.
do=1, and why AD is unreliable without it
AD (Authenticated Data) reports DNSSEC validation. RFC 4035 ยง3.2.3 defines when it may be set; RFC 6840 ยง5.8 says a resolver should set it only when the query carried the DO or AD bit.
Without do=1 the flag varies. On 2026-09-30, 30 queries per type for example.com: A, AAAA and NS returned "AD": false every time, MX returned true every time. With do=1, and from Google either way, every answer had true.
Send do=1 if you intend to read AD. Both resolvers then include RRSIG records in Answer, and the RDATA text is not byte-identical: on 2026-11-03 Google lowercased the covered type mnemonic, Cloudflare uppercased it. Compare sets, not strings.
SERVFAIL is not a diagnosis. EDE is.
A broken chain returns SERVFAIL and AD: false. SERVFAIL alone does not separate "DNSSEC validation failed" from "authoritative servers unreachable". The Extended DNS Error option (RFC 8914) carries a code plus text. For DNSSEC the relevant codes are 1, 2, 5, 6, 7, 8, 9, 10 and 12.
dnssec-failed.org, 2026-11-03:
- Cloudflare:
Status: 2,Comment: ["EDE(9): DNSKEY Missing no SEP matching the DS found for dnssec-failed.org."] - Google:
Status: 2, aCommentbeginning "DNSSEC validation failure", andextended_dns_errors: [{ "info_code": 9, "extra_text": "No DNSKEY matches DS RRs of dnssec-failed.org" }]
Code 9 is "DNSKEY Missing" (RFC 8914 ยง4.10). Cloudflare puts EDE(n): text strings in an array; Google puts a sentence in Comment and the code in extended_dns_errors. Parse the code, not the sentence. Google also adds Comment: "Response from <IP>." on an uncached answer.
The same TXT record, 425 and 420 characters
TXT values are sequences of character-strings, each at most 255 bytes (RFC 1035 ยง3.3). A long DKIM key is stored as several strings, and consumers join them with nothing between (RFC 6376 ยง3.6.2.2).
In the 2026-11-03 query, Cloudflare returned each string quoted and space-separated; Google returned one unquoted string. For cf2024-1._domainkey.zerotool.dev, Cloudflare returned 425 characters with one " " join after the first 255-character string, Google 420. The gap is four quote characters plus the joining space.
Google's documentation shows quoted, segmented TXT examples, so treat quoting as presentation, not a contract. Strip quotes when present, and never insert a separator between segments.
A public resolver is not the system resolver
dig asks the resolver configured in the OS, or the server you name. A DoH call asks Cloudflare or Google: different cache, different network position.
-
/etc/hostsand VPN overrides: application lookups go through them;digandnslookupnormally do not read the hosts file, and a public DoH resolver sees neither. - Split-horizon: internal names return internal addresses inside the network and NXDOMAIN outside.
- Search domains: the OS may append a suffix to a bare hostname; the JSON endpoints need the full name.
- Browser Secure DNS changes which resolver answers navigation, not the resolver the DoH request asks.
- Bootstrap: the page must resolve
cloudflare-dns.comordns.googlefirst, so a filter that blocks them stops the lookup.
Six misdiagnoses
- "Still propagating." Usually caching. Check the TTL, then ask a second resolver; old on one and new on the other means cache expiry.
- "DNS is broken on one machine." Check for a cached NXDOMAIN. RFC 2308 ยง5 lets a resolver cache the negative answer for the SOA minimum, so a fixed zone keeps failing until the timer expires.
- "The CNAME is missing." The resolver followed it and returned A/AAAA, or the apex uses CNAME flattening. Read the
namefield of each answer. - "The TXT record is truncated." It is multiple 255-byte strings. Join them without a separator.
- "The internal name does not resolve." A split-horizon zone answers inside the network and returns NXDOMAIN outside.
- "DNSSEC is fine because the site loads." A validating resolver can fail where a non-validating one succeeds.
NXDOMAIN and NODATA differ. Status: 3 means the name does not exist; NOERROR with zero answers means the name exists without that type.
Reproduce it
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent('zerotool.dev')}&type=MX&do=1&cd=0`;
const res = await fetch(url, { headers: { accept: 'application/dns-json' } });
const json = await res.json();
console.log(json.Answer); // [{ name, type, TTL, data }, ...]
Swap the host to https://dns.google/resolve?name=โฆ&type=โฆ&do=1&cd=0 and add edns_client_subnet to see the ECS effect. Add AbortSignal.timeout(5000) and a try/catch; the tool cancels each query after 5 seconds.
One HTTP detail matters: in the same 2026-11-03 query, Google's response carried cache-control: private, max-age=300 and Cloudflare's carried none. A repeat query can be served from a browser or intermediary cache for up to five minutes, which looks like a resolver that ignored your change.
The longer write-up, including record types, CAA, PTR and SRV, is in the DoH lookup guide.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.