Two public resolvers answer DNS queries over HTTPS in JSON: Cloudflare at https://cloudflare-dns.com/dns-query and Google at https://dns.google/resolve. Both send access-control-allow-origin: *, so a web page can call them with fetch(). The responses look the same, and that is intentional. Cloudflare's JSON documentation says: "There is no agreed-upon JSON schema for DNS over HTTPS in the Internet Engineering Task Force (IETF), so Cloudflare has chosen to follow the same schema as Google's DNS over HTTPS resolver." The same page warns that "behavior might be different between providers."
It is. If you treat either endpoint as dig with JSON output, you can report a signed zone as unsigned, break a DKIM key, or show NOERROR when nothing answered. This article shows five differences, each with a real request and response, and ends with a small client that handles all of them.
All requests ran on 2026-09-30 between 02:58 and 03:14 UTC, with curl 8.7.1 and Node 24.14 on macOS. TTLs, signatures and CDN addresses change. Run the commands again for current values.
Why query DoH from the browser
The first reason is that you need no backend. RFC 8484 names this use case: "allowing web applications to access DNS information via existing browser APIs in a safe way consistent with Cross Origin Resource Sharing (CORS)". Note that RFC 8484 defines the binary application/dns-message format only. The JSON format is a de facto API with no RFC.
The second reason is that DoH skips the resolver on your machine. That resolver is not always what you think. This is dig on a laptop that runs a proxy app in fake-IP mode:
$ dig @1.1.1.1 dnssec-failed.org A +noall +comments +answer
; <<>> DiG 9.10.6 <<>> @1.1.1.1 dnssec-failed.org A +noall +comments +answer
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 28444
;; flags: qr ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; ANSWER SECTION:
dnssec-failed.org. 1 IN A 198.18.2.147
The command asks 1.1.1.1, but 1.1.1.1 did not answer. The proxy captured the UDP packet and replied with an address from 198.18.0.0/15. RFC 5735 says that block "has been allocated for use in benchmark tests of network interconnect devices" (RFC 2544), and RFC 6890 marks it "Global: False". It is not a public host. Proxy apps use it as a pool of placeholder addresses; the example configuration in mihomo's DNS documentation sets fake-ip-range: 198.18.0.1/16. The proxy then routes the connection by name. For browsing, this works. For DNS troubleshooting, it hides the result. dnssec-failed.org has a DNSSEC error on purpose; it is the failure example in Google's JSON API documentation. A validating resolver returns SERVFAIL for it.
The same query over DoH, on the same laptop, a minute later:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=dnssec-failed.org&type=A'
{"Status":2,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"dnssec-failed.org","type":1}],"Comment":["EDE(9): DNSKEY Missing no SEP matching the DS found for dnssec-failed.org."]}
The HTTPS connection also went through the proxy, but TLS ends at Cloudflare, so the proxy cannot change the answer. Status 2 is SERVFAIL.
1. AD means little unless you send do=1
The AD field reports the Authenticated Data bit: the resolver validated the answer with DNSSEC. example.com is signed. Here are both resolvers without extra parameters, at 02:59:38 UTC:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=example.com&type=A'
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"example.com","type":1}],"Answer":[{"name":"example.com","type":1,"TTL":195,"data":"104.20.23.154"},{"name":"example.com","type":1,"TTL":195,"data":"172.66.147.243"}]}
$ curl -s 'https://dns.google/resolve?name=example.com&type=A'
{"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":300,"data":"104.20.23.154"},{"name":"example.com.","type":1,"TTL":300,"data":"172.66.147.243"}],"Comment":"Response from 108.162.192.162."}
Same records, different AD. The Cloudflare value is also not stable. Each query below ran 30 times between 03:05 and 03:08 UTC. The cells count responses with AD true:
| Query | Cloudflare, no do
|
Cloudflare, do=1
|
Google, no do
|
Google, do=1
|
|---|---|---|---|---|
| example.com A | 0 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
| example.com AAAA | 0 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
| example.com NS | 0 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
| example.com MX | 30 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
| ietf.org A | 30 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
| jprs.jp A | 30 of 30 | 30 of 30 | 30 of 30 | 30 of 30 |
In an earlier run of 20 queries for example.com A without do, 10 had AD true. At 03:09 UTC, the same query returned "AD":true. Cloudflare's documentation does not say when it sets the bit for a query without DO.
The standard allows this. RFC 6840 §5.8 says validating resolvers "SHOULD only set the AD bit when a response both meets the conditions listed in Section 3.2.3 of [RFC4035], and the request contained either a set DO bit or a set AD bit." Without DO, AD: false does not mean "not validated". A client that shows a DNSSEC badge must send do=1. With it, both resolvers set AD on every signed answer above.
do=1 has a cost: the answer now contains RRSIG records (type 46). The two resolvers also write the type-covered field of the RRSIG data differently:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=example.com&type=A&do=1' | jq -r '.AD, (.Answer[] | select(.type == 46) | .data)'
true
A 13 2 300 1790827762 1790647762 34505 example.com. LA+CGxWAjI1xT3z4P8MFTjHfIMA2LuvUJczw6KuIStoOG6ITvBpC4xmtJgLp41T5c0nxR6PAxVc1oQCpPpCRpg==
$ curl -s 'https://dns.google/resolve?name=example.com&type=A&do=1' | jq -r '.AD, (.Answer[] | select(.type == 46) | .data)'
true
a 13 2 300 1790827791 1790647791 34505 example.com. eQuOSdppPfyHSsUscjb+uTA/yoAsSfDyar3QEAnmdnXnKwJ6xSy8Gbw08m8Fyb+Vo5t8drkn3DwFEBI2CfzZDA==
If your code counts records or takes Answer[0], filter type 46 first. Google's JSON API documentation asks for this anyway: "Applications should always handle (and ignore, if necessary) any DNSSEC records in JSON responses as other implementations may always include them".
2. TXT: quoted strings or one joined string
A TXT record holds one or more character-strings. RFC 1035 §3.3 limits each to "256 characters in length (including the length octet)", so 255 bytes of data. Long SPF and DKIM records are split. Short records show the first difference:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=zerotool.dev&type=TXT' | jq -r '.Answer[].data'
"google-site-verification=LuIjDQw7A4X7S8O4B5dAl43kORDqhMLSA8fHzj_ZneE"
"v=spf1 include:_spf.mx.cloudflare.net ~all"
$ curl -s 'https://dns.google/resolve?name=zerotool.dev&type=TXT' | jq -r '.Answer[].data'
v=spf1 include:_spf.mx.cloudflare.net ~all
google-site-verification=LuIjDQw7A4X7S8O4B5dAl43kORDqhMLSA8fHzj_ZneE
Cloudflare returns the zone-file form, with quotes. Google returns the value. (The record order also differs. RFC 1034 §3.6 says: "The order of RRs in a set is not significant". Do not depend on it.)
A 2048-bit DKIM key shows the second difference:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=cf2024-1._domainkey.zerotool.dev&type=TXT' | jq -r '.Answer[0].data'
"v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiweykoi+o48IOGuP7GR3X0MOExCUDY/BCRHoWBnh3rChl7WhdyCxW3jgq1daEjPPqoi7sJvdg5hEQVsgVRQP4DcnQDVjGMbASQtrY4WmB1VebF+RPJB2ECPsEDTpeiI5ZyUAwJaVX7r6bznU67g7LvFq35yIo4sdlmtZGV+i0H4cpYH9+3JJ78k" "m4KXwaf9xUJCWF6nxeD+qG6Fyruw1Qlbds2r85U9dkNDVAS3gioCvELryh1TxKGiVTkg4wqHTyHfWsp7KD3WQHYJn0RyfJJu6YEmL77zonn7p2SRMvTMP3ZEXibnC9gz3nnhR6wcYL8Q7zXypKTMD58bTixDSJwIDAQAB"
$ curl -s 'https://dns.google/resolve?name=cf2024-1._domainkey.zerotool.dev&type=TXT' | jq -r '.Answer[0].data'
v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiweykoi+o48IOGuP7GR3X0MOExCUDY/BCRHoWBnh3rChl7WhdyCxW3jgq1daEjPPqoi7sJvdg5hEQVsgVRQP4DcnQDVjGMbASQtrY4WmB1VebF+RPJB2ECPsEDTpeiI5ZyUAwJaVX7r6bznU67g7LvFq35yIo4sdlmtZGV+i0H4cpYH9+3JJ78km4KXwaf9xUJCWF6nxeD+qG6Fyruw1Qlbds2r85U9dkNDVAS3gioCvELryh1TxKGiVTkg4wqHTyHfWsp7KD3WQHYJn0RyfJJu6YEmL77zonn7p2SRMvTMP3ZEXibnC9gz3nnhR6wcYL8Q7zXypKTMD58bTixDSJwIDAQAB
Cloudflare gives two quoted strings of 255 and 165 characters, joined by " ". Google gives one 420-character string. Look at …JJ78k" "m4KX… in the first output and …JJ78km4KX… in the second.
Google's form is the value that SPF and DKIM verifiers use. RFC 7208 §3.3 says SPF strings "MUST be treated as if those strings are concatenated together without adding spaces", and RFC 6376 §3.6.2.2 says the same for DKIM: "Strings in a TXT RR MUST be concatenated together before use with no intervening whitespace." If you paste Cloudflare's data into a DKIM checker, the key has a quote, a space and a quote in the middle.
Google's JSON page states that "All TXT records are encoded as a single JSON string", but its own DKIM example on that page still shows quoted strings. Trust the live response, and parse both forms.
3. Failure details are in different fields
Here is Google's answer for the broken zone:
$ curl -s 'https://dns.google/resolve?name=dnssec-failed.org&type=A'
{"Status":2,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"dnssec-failed.org.","type":1}],"Comment":"DNSSEC validation failure. Check http://dnsviz.net/d/dnssec-failed.org/dnssec/ and http://dnssec-debugger.verisignlabs.com/dnssec-failed.org for errors","extended_dns_errors":[{"info_code":9,"extra_text":"No DNSKEY matches DS RRs of dnssec-failed.org"}]}
Compare it with the Cloudflare response in the first section. Both report Extended DNS Error 9, "DNSKEY Missing", which RFC 8914 §4.10 defines as: "A DS record existed at a parent, but no supported matching DNSKEY record could be found for the child." The fields are different:
- Cloudflare puts EDEs in
Comment, as an array of strings. Its documentation definesCommentas "List of EDE messages". - Google puts a sentence in
Comment, as one string, and the structured code inextended_dns_errors. That field is not on Google's JSON API page.
Cloudflare's EDE can also appear on a successful answer. With cd=1 (validation off), the answer is NOERROR with an address, and the note stays:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=dnssec-failed.org&type=A&cd=1'
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":true,"Question":[{"name":"dnssec-failed.org","type":1}],"Answer":[{"name":"dnssec-failed.org","type":1,"TTL":300,"data":"96.99.227.255"}],"Comment":["EDE(9): DNSKEY Missing no SEP matching the DS found for dnssec-failed.org."]}
Google's Comment on a successful answer is often Response from <IP>, as in the example.com response above. Google's documentation explains: "Uncached responses are attributed to the authoritative name server." That is useful for debugging, but it is not an error.
So Comment can be a string or an array, and it can hold an error or only a server address. Normalize it to an array, and read Status for the outcome.
4. EDNS Client Subnet changes the answer
Google Public DNS "normally sends approximate network information (usually zeroing out the last part of your IPv4 address)" to authoritative servers (JSON API). This is EDNS Client Subnet (ECS), RFC 7871. Its privacy section (§11.1) encourages resolvers to truncate "IPv4 addresses to 24 bits". The Cloudflare FAQ says 1.1.1.1 "does not send the EDNS Client Subnet (ECS) header", with one exception for an Akamai debug domain.
A CDN can therefore give each resolver a different address. Google's API lets you set the subnet with edns_client_subnet, so you can see the effect from any location:
$ curl -s 'https://dns.google/resolve?name=www.qq.com&type=A&edns_client_subnet=114.114.114.0/24' | jq -c '{edns_client_subnet, A: [.Answer[] | select(.type == 1) | .data]}'
{"edns_client_subnet":"114.114.114.0/24","A":["121.14.77.201","121.14.77.221"]}
$ curl -s 'https://dns.google/resolve?name=www.qq.com&type=A&edns_client_subnet=8.8.8.0/24' | jq -c '{edns_client_subnet, A: [.Answer[] | select(.type == 1) | .data]}'
{"edns_client_subnet":"8.8.8.0/24","A":["43.159.109.55"]}
$ curl -s 'https://dns.google/resolve?name=www.qq.com&type=A&edns_client_subnet=139.130.4.0/24' | jq -c '{edns_client_subnet, A: [.Answer[] | select(.type == 1) | .data]}'
{"edns_client_subnet":"139.130.4.0/24","A":["43.168.224.173"]}
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=www.qq.com&type=A&edns_client_subnet=114.114.114.0/24' | jq -c '{edns_client_subnet, A: [.Answer[] | select(.type == 1) | .data]}'
{"edns_client_subnet":null,"A":["43.159.109.55"]}
Three subnets, three answers. The /24 after each subnet in the response is the scope that the authoritative server returned: this answer is valid only for that /24. Cloudflare's supported parameters are name, type, do and cd. It ignored edns_client_subnet and returned no such field.
Neither resolver is wrong. When a user says "the site resolves to a different IP for me", check which resolver they use before you suspect the zone. To stop Google from sending your subnet, use edns_client_subnet=0.0.0.0/0.
5. Names: what goes in and what comes back
Trailing dot. Google returns fully qualified names with a trailing dot. Cloudflare returns names without it, even when your query has one:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=zerotool.dev&type=A' | jq -c '[.Question[0].name, .Answer[0].name]'
["zerotool.dev","zerotool.dev"]
$ curl -s 'https://dns.google/resolve?name=zerotool.dev&type=A' | jq -c '[.Question[0].name, .Answer[0].name]'
["zerotool.dev.","zerotool.dev."]
Remove one trailing dot before you compare names or match a CNAME chain.
Non-ASCII names. Both resolvers reject Unicode names. Google's documentation says: "Non-ASCII characters should be punycoded". The error bodies differ:
$ curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=%E6%97%A5%E6%9C%AC%E8%AA%9E.jp&type=A' -w '\n%{http_code} %{content_type}\n'
{"error":"Invalid query name `日本語.jp`."}
400
$ curl -s 'https://dns.google/resolve?name=%E6%97%A5%E6%9C%AC%E8%AA%9E.jp&type=A' -o /dev/null -w '%{http_code} %{content_type}\n'
400 text/html; charset=UTF-8
Cloudflare sends a JSON error without a content type. Google sends an HTML page. Code that calls res.json() without a status check throws a parse error on Google and hides the cause. Cloudflare also returns 400 when the accept: application/dns-json header is missing.
The browser's URL parser converts names for you. It applies IDNA, and it maps full-width letters and the ideographic full stop 。 to ASCII:
new URL('http://日本語。jp').hostname; // 'xn--wgv71a119e.jp'
new URL('http://EXAMPLE.com').hostname; // 'example.com'
Underscores. Mail checks query _dmarc.example.com (RFC 7489 §6.1) and selector._domainkey.example.com (RFC 6376 §3.6.2.1). A hostname regex (letters, digits, hyphens) rejects both. DNS does not: RFC 2181 §11 says "The DNS itself places only one restriction on the particular labels that can be used to identify resource records. That one restriction relates to the length of the label and the full name." RFC 8552 describes the underscore convention. Validate length and label syntax, and allow _.
Merging an "ALL" query
Neither API has a useful "all types" query. Cloudflare answers ANY with NOTIMPL, and Google says ANY "is not a replacement for sending queries for both A and AAAA or MX records". A lookup page sends one query per type and merges the results. Three rules keep the merged result honest:
-
ADis true only if every response hasAD: true. One unsigned answer makes the set unvalidated. - The status is NOERROR if any type answered NOERROR. Otherwise, report the error that the types returned. NXDOMAIN applies to the name, so all types return it together.
- If no request got a DNS response at all, there is no status. Do not start from
Status: 0and report NOERROR for a network failure.
This client uses do=1, removes RRSIG, normalizes names, TXT and notes, and applies the three rules:
const RESOLVERS = {
cloudflare: 'https://cloudflare-dns.com/dns-query',
google: 'https://dns.google/resolve',
};
const RRSIG = 46;
const TXT = 16;
async function query(resolver, name, type) {
const url = new URL(RESOLVERS[resolver]);
url.search = new URLSearchParams({ name, type, do: '1' });
// Cloudflare needs this header. Google ignores it.
const res = await fetch(url, { headers: { accept: 'application/dns-json' } });
// Google sends an HTML page with HTTP 400, so check before .json().
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Cloudflare: Comment is an array of EDE strings.
// Google: Comment is one string, EDEs are in extended_dns_errors.
function notes(r) {
const c = r.Comment == null ? [] : [].concat(r.Comment);
const ede = (r.extended_dns_errors ?? []).map(e => `EDE(${e.info_code}): ${e.extra_text}`);
return [...c, ...ede];
}
// '"abc" "def"' (Cloudflare) and 'abcdef' (Google) both become 'abcdef'.
// \DDD escapes become one char per byte; enough for ASCII records like SPF and DKIM.
export function txtValue(data) {
if (!data.startsWith('"')) return data;
return [...data.matchAll(/"((?:[^"\\]|\\.)*)"/g)]
.map(m => m[1].replace(/\\(\d{3}|.)/g, (_, e) => e.length === 3 ? String.fromCharCode(+e) : e))
.join('');
}
export async function lookup(resolver, name, types = ['A', 'AAAA', 'MX', 'TXT']) {
const settled = await Promise.allSettled(types.map(t => query(resolver, name, t)));
const ok = settled.filter(s => s.status === 'fulfilled').map(s => s.value);
const errors = settled.flatMap((s, i) =>
s.status === 'rejected' ? [`${types[i]}: ${s.reason.message}`] : []);
if (ok.length === 0) return { status: null, errors }; // no DNS answer at all
const statuses = ok.map(r => r.Status);
const answers = ok.flatMap(r => r.Answer ?? []);
return {
status: statuses.includes(0) ? 0 : statuses[0],
ad: ok.every(r => r.AD === true),
records: answers
.filter(a => a.type !== RRSIG)
.map(a => ({
name: a.name.replace(/\.$/, ''),
type: a.type,
data: a.type === TXT ? txtValue(a.data) : a.data,
})),
rrsigs: answers.filter(a => a.type === RRSIG).length,
notes: [...new Set(ok.flatMap(notes))],
errors,
};
}
I tested it with Node 24.14. It uses only fetch and URL, so it also works as a browser module:
import { lookup } from './doh.mjs';
const ex = await lookup('cloudflare', 'example.com');
console.log(ex.status, ex.ad, ex.records.length, ex.rrsigs);
console.log(await lookup('google', 'dnssec-failed.org'));
console.log(await lookup('cloudflare', '_dmarc.zerotool.dev', ['TXT']));
0 true 7 4
{
status: 2,
ad: false,
records: [],
rrsigs: 0,
notes: [
'DNSSEC validation failure. Check http://dnsviz.net/d/dnssec-failed.org/dnssec/ and http://dnssec-debugger.verisignlabs.com/dnssec-failed.org for errors',
'EDE(9): No DNSKEY matches DS RRs of dnssec-failed.org'
],
errors: []
}
{ status: 3, ad: false, records: [], rrsigs: 0, notes: [], errors: [] }
Four signed RRsets give 7 records and 4 signatures, and ad is true. The broken zone is SERVFAIL (2) with both of Google's notes. _dmarc.zerotool.dev is NXDOMAIN (3): that domain has no DMARC record. For the same DKIM name, txtValue gives identical 420-character records from both resolvers.
The third rule matters in practice. During one test run, all four requests failed at the network level, and the function returned this:
{
status: null,
errors: [
'A: fetch failed',
'AAAA: fetch failed',
'MX: fetch failed',
'TXT: fetch failed'
]
}
A merge that starts from Status: 0 shows that as "NOERROR, 0 records".
When to use dig instead
The JSON APIs answer one question: what does this public resolver return now? Use dig or a wire-format DoH client when you need more:
-
One specific server. To ask an authoritative server (
dig @ns1.example.com), you need a direct DNS query. Both JSON APIs are recursive only. - Full control of the query. Cloudflare's JSON API accepts four parameters. EDNS options, other classes and exact flags need the binary format. Cloudflare recommends it "for critical use cases".
- Server-side scripts. On a server, CORS does not apply, and the wire format has an RFC.
On a machine with a VPN or a proxy app, remember the first example. dig shows what the local network stack answers. That is sometimes the question, but often it is not.
Reproduce
Every command above works with curl and jq. To compare both resolvers side by side in a browser, use a DNS lookup page that queries both over DoH. It sends do=1, hides RRSIG in the Records view, and shows the raw JSON.

dnssec-failed.org through Cloudflare: SERVFAIL, with the EDE(9) note from Comment.

The DKIM key through Cloudflare: two quoted strings. Switch the resolver to Google to see one joined string.
Top comments (1)
Useful measured differences. For anyone doing domain checks in app code: treat SERVFAIL and timeouts as "could not check", not as "domain does not exist". NXDOMAIN is the negative answer; a resolver outage that returns SERVFAIL is not.