Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

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, a Comment beginning "DNSSEC validation failure", and extended_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/hosts and VPN overrides: application lookups go through them; dig and nslookup normally 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.com or dns.google first, so a filter that blocks them stops the lookup.

Six misdiagnoses

  1. "Still propagating." Usually caching. Check the TTL, then ask a second resolver; old on one and new on the other means cache expiry.
  2. "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.
  3. "The CNAME is missing." The resolver followed it and returned A/AAAA, or the apex uses CNAME flattening. Read the name field of each answer.
  4. "The TXT record is truncated." It is multiple 255-byte strings. Join them without a separator.
  5. "The internal name does not resolve." A split-horizon zone answers inside the network and returns NXDOMAIN outside.
  6. "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.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.