---
title: "SERVFAIL Is Not NXDOMAIN, and Conflating Them Accepts Bad Addresses"
description: "DNS Status 2 means the resolver broke; Status 3 means the domain does not exist. Treating them alike silently corrupts email validation."
slug: "servfail-vs-nxdomain-email-validation"
date: 2026-08-24
updated: 2026-08-24
last_tested: 2026-08-24
summary: "A resolver failure and a nonexistent domain arrive as different DNS status codes and demand opposite responses: fail open on one, reject on the other."
cluster: "DNS and RFCs"
intent: engineering
sources:
  - title: "RFC 1035 — DNS response codes (RCODE)"
    url: "https://www.rfc-editor.org/rfc/rfc1035.html#section-4.1.1"
  - title: "RFC 2308 — Negative caching of DNS queries"
    url: "https://www.rfc-editor.org/rfc/rfc2308.html"
  - title: "IANA DNS RCODE registry"
    url: "https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6"
  - title: "Cloudflare DNS over HTTPS JSON API"
    url: "https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/"
---

A DNS lookup that does not return records has not necessarily told you the domain is bad. It might have told you the resolver could not answer. Those are different facts, they arrive as different status codes, and they require opposite handling. Most email validators built in a hurry collapse them into one `if (!records) reject` branch.

## The two status codes

DNS-over-HTTPS returns a JSON object with a numeric `Status` field, carrying the RCODE from RFC 1035:

- **Status 3, NXDOMAIN.** The authoritative nameserver for the parent zone says this name does not exist. This is a definitive negative answer. `outlook.con` returns it, because `.con` is not a delegated top-level domain.
- **Status 2, SERVFAIL.** Something in the resolution path broke. A DNSSEC validation failure, an unresponsive authoritative server, a timeout, a lame delegation. The resolver is reporting its own inability to answer, and says nothing about whether the domain exists.

`gmial.com` returns SERVFAIL. That is a real, reproducible result against a public resolver, not a transient blip — a persistently broken delegation looks the same as a momentary outage from the client's side.

## Why collapsing them is worse than it looks

Consider the naive branch:

```js
// Wrong. Two different facts, one response.
const answer = await resolveMx(domain);
if (answer.Status !== 0 || !answer.Answer) {
  return { verdict: "undeliverable", reason: "domain_not_found" };
}
```

Run this during a resolver incident. Every domain you check, including `gmail.com`, returns Status 2 for the duration. Your validator now reports every address your users type as undeliverable, your signup form rejects everyone, and your logs show a clean `domain_not_found` for each one. The failure is invisible in aggregate metrics because the verdict distribution looks like a sudden wave of bad traffic rather than an outage.

Now invert it. Some implementations, having been burned by exactly that, flip to accepting on any error. Those systems then accept genuinely nonexistent domains whenever the resolver is unhappy, and the addresses land in the database looking fully validated.

Neither direction is safe, because the question "should I accept this" has no single correct answer for a lookup that did not complete. The only correct move is to stop pretending you know.

## Fail open, but say so in the response

nobounce splits the two cases and encodes the split in the verdict object:

| DNS result | verdict | reason | `checked.mx` |
|---|---|---|---|
| NXDOMAIN (Status 3) | `undeliverable` | `domain_not_found` | `true` |
| SERVFAIL (Status 2) | `unknown` | `dns_error` | `false` |
| NOERROR, null MX | `undeliverable` | `null_mx` | `true` |
| NOERROR, no MX and no A/AAAA | `undeliverable` | `no_mx_no_a` | `true` |

The verdict `unknown` paired with `checked.mx: false` is the contractual fail-open signal. It means: syntax and typo checks ran and found nothing wrong, the DNS check did not complete, and this is not an endorsement. A degraded check is never reported as `deliverable`, because the entire value of a `deliverable` verdict is that it was actually earned.

That field exists for a conversation you will eventually have. A customer asks why a bad address was accepted six weeks ago. With `checked.mx` recorded alongside the verdict you can answer honestly: the lookup did not complete, we said `unknown`, your integration chose to proceed. Without it, you are guessing.

## Never cache a failure

The second half of the rule matters as much as the first. A lookup that failed must not write a cache row.

If SERVFAIL is cached with the same time-to-live as a successful negative answer, a brief resolver problem pins a wrong verdict for the whole cache window. Users who signed up during a thirty-second incident keep getting rejected for hours, long after DNS recovered. RFC 2308 governs negative caching for definitive negative answers; a server failure is not one of those.

nobounce verifies this behaviourally rather than by inspection: after checking a SERVFAIL domain, the domain cache table has no row for it. Successful verdicts cache for six hours positive and one hour negative, shared across all customers. Failures cache for zero seconds.

## Handling `unknown` in your own code

Fail-open is only a coherent policy if the caller has a policy too. Branch on the verdict rather than on truthiness:

```js
const result = await check(email);

if (result.verdict === "undeliverable" && result.suggestion) {
  return offerCorrection(result.suggestion);
}
if (result.verdict === "undeliverable") {
  return rejectWithReason(result.reason);
}
// unknown, risky and deliverable all proceed. Record the verdict so a later
// audit can distinguish "we verified this" from "we could not verify this".
return acceptSignup({ emailVerdict: result.verdict, mxChecked: result.checked.mx });
```

Validation should never become a hard dependency of signup. If the validator is down, users still get in — and you keep a record of which ones arrived unverified, so a cleanup pass is possible later. That is a very different position from having silently accepted them with no marker at all.

The frozen taxonomy of verdicts and reasons is served at `/v1/config`, so an integration can assert against the exact enum rather than string-matching prose.
