Skip to content

DNS-over-HTTPS for domain checks in serverless code

· 7 min read · Email validation

If your code runs somewhere without a usable system resolver (an edge function, a Worker, a locked-down serverless runtime), you can still do DNS checks by calling a DNS-over-HTTPS JSON endpoint with fetch. Send GET https://cloudflare-dns.com/dns-query?name=example.com&type=MX with accept: application/dns-json, or GET https://dns.google/resolve?name=example.com&type=MX, and parse the Status and Answer fields. The rest of this post covers what those fields mean, where the two providers differ, and how to avoid turning resolver hiccups into rejected signups.

What to do with the answers (null MX, implicit MX, which failures should block) is covered in checking MX records to see if a domain can receive email. This post is about the transport.

Why DoH fits serverless better than a resolver library

Classic DNS is UDP on port 53, with TCP fallback. Edge runtimes built around the Fetch API typically give you HTTP and nothing else, and even where raw sockets exist, you don't control the resolver configuration of the host. DoH sidesteps all of it: a DNS query is an HTTPS request, so it works anywhere fetch works, goes through the same egress, and is encrypted on the way.

Cloudflare Workers are a useful reference point. Their node:dns compatibility layer is itself built on DoH to 1.1.1.1. It supports resolveMx and friends but not lookup or resolve, and each query counts as a subrequest. If that fits your code, use it. Calling the JSON API directly gives you more control: provider choice, timeouts, and access to the raw status code.

Two flavours of DoH

RFC 8484 is the standard. It carries ordinary binary DNS messages over HTTPS with the media type application/dns-message, sent either as a POST body or as a base64url dns parameter on a GET. To use it you need a library that builds and parses DNS wire format.

The JSON API is what most people actually use from application code. It's simpler: query string in, JSON out. It is not part of RFC 8484, though. Cloudflare's docs state plainly that "the DNS over HTTPS JSON format does not have a formal RFC" and suggest wire format for critical use. For validation checks where a wrong answer means "unknown" rather than an outage, JSON is fine.

The request and response

curl -H 'accept: application/dns-json' \
  'https://cloudflare-dns.com/dns-query?name=gmail.com&type=MX'
{
  "Status": 0,
  "TC": false, "RD": true, "RA": true, "AD": false, "CD": false,
  "Question": [{ "name": "gmail.com", "type": 15 }],
  "Answer": [
    { "name": "gmail.com", "type": 15, "TTL": 613, "data": "5 gmail-smtp-in.l.google.com." },
    { "name": "gmail.com", "type": 15, "TTL": 613, "data": "10 alt1.gmail-smtp-in.l.google.com." }
  ]
}

(Trimmed. The real answer has five MX records, in no particular order.)

Things to know about the fields:

  • Status is the DNS response code, not an HTTP status. 0 is NOERROR, 2 is SERVFAIL, 3 is NXDOMAIN. HTTP 200 with Status: 2 is a normal, common result.
  • No Answer with Status: 0 means the name exists but has no records of that type (NODATA). You'll usually see an Authority section with the zone's SOA instead.
  • type is numeric in responses: 1 for A, 28 for AAAA, 15 for MX, 5 for CNAME. Both providers accept either the number or the name in the request.
  • data is text. For MX it's "<preference> <exchange>." with a trailing dot. A null MX comes back as "0 .", which example.com publishes today.
  • Answer can include records you didn't ask for. Query www.github.com for A and the answer contains a CNAME (type 5) to github.com. followed by the A record. Filter by the type you asked for before counting answers.

Where Cloudflare and Google differ

Cloudflare Google
Endpoint https://cloudflare-dns.com/dns-query https://dns.google/resolve
Accept header Required: without accept: application/dns-json you get HTTP 400 Not needed for JSON
Unicode names HTTP 400 with a JSON error field HTTP 400 with an HTML error page; the docs say to punycode non-ASCII names
Names in Question/Answer As sent (no trailing dot in Question) Fully qualified, with trailing dot
Comment Array of strings, e.g. Extended DNS Error text on failures String, e.g. which authoritative server answered
Extras do, cd do, cd, ct, edns_client_subnet, random_padding

None of this matters if you only read Status, Answer[].type and Answer[].data, which is a good reason to only read those. Don't parse Comment; it's diagnostic text with no fixed format.

Google's edns_client_subnet=0.0.0.0/0 avoids sending any part of your IP to authoritative servers. For server-side validation that's irrelevant (the client IP is your function's egress), but if you ever proxy DoH from a browser, use it.

A small DoH helper with fallback

Two providers, a timeout on each, and DNS errors reported as data rather than exceptions. Runs in Node 18+, Deno, Bun and Workers, since it only uses fetch and AbortSignal.timeout.

const PROVIDERS = [
  { url: 'https://cloudflare-dns.com/dns-query', headers: { accept: 'application/dns-json' } },
  { url: 'https://dns.google/resolve', headers: {} },
];

const TYPES = { A: 1, MX: 15, AAAA: 28 };

// One DoH JSON query with a timeout and provider fallback.
// Throws only if every provider fails; DNS-level failures come back as Status.
async function doh(name, type, { timeoutMs = 2500 } = {}) {
  let lastErr;
  for (const p of PROVIDERS) {
    const url = `${p.url}?name=${encodeURIComponent(name)}&type=${type}`;
    try {
      const res = await fetch(url, { headers: p.headers, signal: AbortSignal.timeout(timeoutMs) });
      if (!res.ok) throw new Error(`HTTP ${res.status} from ${p.url}`);
      const body = await res.json();
      const answers = (body.Answer ?? []).filter((r) => r.type === TYPES[type]);
      return { status: body.Status, answers };
    } catch (err) {
      lastErr = err;
    }
  }
  throw lastErr;
}

export async function domainFacts(domain) {
  const [mx, a, aaaa] = await Promise.allSettled([
    doh(domain, 'MX'),
    doh(domain, 'A'),
    doh(domain, 'AAAA'),
  ]);
  if (a.status === 'rejected') return { status: 'unknown', error: String(a.reason) };
  if (a.value.status === 3) return { status: 'nxdomain' };
  if (a.value.status !== 0) return { status: 'unknown', rcode: a.value.status };

  const mxHosts = mx.status === 'fulfilled' ? mx.value.answers.map((r) => r.data) : null;
  return {
    status: 'exists',
    mx: mxHosts, // e.g. ["10 alt1.gmail-smtp-in.l.google.com."], ["0 ."] for null MX, null if unknown
    hasAddress: a.value.answers.length > 0 || (aaaa.status === 'fulfilled' && aaaa.value.answers.length > 0),
  };
}

Against live DNS this returns { status: 'exists', mx: ['0 .'], hasAddress: true } for example.com, { status: 'nxdomain' } for an unregistered name, and an empty mx array with hasAddress: true for xn--bcher-kva.de (an implicit-MX domain).

Design choices worth copying:

  • Fallback only on transport failure. A SERVFAIL is a valid DNS answer and is returned as-is. If you want, retry SERVFAIL on the second provider too: resolvers can disagree when a domain's nameservers are flaky, and a second opinion is cheap. Just don't loop.
  • Queries run in parallel. Three sequential HTTPS round trips add up in a signup request. Promise.allSettled keeps one failing record type from sinking the others.
  • Existence is decided on the A query. NXDOMAIN applies to the name regardless of record type, so any of the three would do. Picking one keeps the logic simple.
  • Short timeouts. 2.5 seconds per provider, worst case about five. In a request path, consider less and fail open.

Make the input DoH-safe first

Normalize before you query. That means lowercase, strip a trailing dot, reduce to the registrable domain if you're checking an email's organization, and convert internationalized names to punycode. Cloudflare answers a query for bücher.de with HTTP 400 and {"error":"Invalid query name ..."}, and Google with an HTML 400 page (so check res.ok before calling res.json()). The same query as xn--bcher-kva.de works on both. The conversion has some traps (IDNA2003 vs IDNA2008, libraries that don't lowercase), covered in internationalized email domains and punycode. In JavaScript, new URL('http://' + host).hostname does a correct conversion with no dependencies.

Caching and budget

DoH responses carry TTLs, and RFC 8484 ties HTTP freshness to the smallest TTL in the answer. Your own application cache can be more relaxed for validation purposes: a domain that had MX records an hour ago almost certainly still does. Key on the normalized domain, cache positive results for hours or days, and cache "unknown" for minutes at most so a transient SERVFAIL doesn't stick. Caching domain lookups has more on keys and TTLs.

On Workers, every DoH call is a subrequest, so three queries per signup triples your subrequest usage for that path. A KV or in-memory cache in front pays for itself quickly.

Or let something else do it

This is the approach pingutil takes internally: on check_dns: true it runs A, MX, AAAA and www A queries in parallel against Cloudflare's JSON endpoint with a four-second timeout, caches the result for seven days, and returns null instead of guessing if the lookup fails. The output is three booleans in domain_signals:

const res = await fetch('https://pingutil.com/v1/lookup', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${env.PINGUTIL_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({ input: 'jane@preparmy.com', skip_enrichment: true, check_dns: true }),
});
const { domain_signals } = await res.json();
// { domain_exists: true, can_receive_email: true, has_website: false }

has_website: false on a domain that does take mail is a signal in its own right; signup domains with no website explains what it tends to mean. For a full edge-runtime example, see email validation at the edge with Cloudflare Workers.

Checklist before you ship a DoH check: punycode the name, read only Status, Answer[].type and Answer[].data, filter answers by type, run queries in parallel with timeouts, and treat anything other than NOERROR or NXDOMAIN as unknown.

Questions people ask

Is the DNS-over-HTTPS JSON format a standard?
No. RFC 8484 standardizes DoH with binary DNS messages using the application/dns-message media type. The JSON format offered by Cloudflare and Google is a de facto convention, and Cloudflare's own docs note it has no formal RFC.
Can I use node:dns in Cloudflare Workers?
Yes. Cloudflare documents node:dns support that resolves over DNS-over-HTTPS through 1.1.1.1, with lookup, lookupService and resolve not implemented. Each query counts against the Worker's subrequest limit.
Why does the DoH API return 400 for my domain?
A common cause is sending an internationalized name in Unicode. Convert it to punycode (A-labels) first. With Cloudflare, a missing accept: application/dns-json header also returns 400.