---
title: "Fail Open: Email Validation Must Never Block a Signup"
description: "How to wire email validation into a signup flow so an outage in the validator cannot cost you registrations."
slug: "fail-open-email-validation-signup"
date: 2026-08-24
updated: 2026-08-24
last_tested: 2026-08-24
summary: "Treat the validator as advisory infrastructure: set a timeout, branch on the verdict, record what was actually checked, and let users through when it cannot answer."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "RFC 5321 — Simple Mail Transfer Protocol"
    url: "https://www.rfc-editor.org/rfc/rfc5321.html"
  - title: "nobounce.dev integration guide"
    url: "https://nobounce.dev/integrate.md"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

Put a synchronous third-party HTTP call in the middle of your registration handler and you have made someone else's uptime into your conversion rate. Email validation is worth doing anyway. The way to have both is to decide, before you write the call, what happens when it does not answer.

## The rule

Validation is advisory. It may change what you show the user, and it may annotate the record you write. It may never be the reason a signup fails when the validator itself is the thing that broke.

That sounds obvious and is routinely violated, usually not by a deliberate decision but by a `try` block whose `catch` returns a 400.

## Step 1: bound the call

Every validator call needs an explicit timeout, and it should be short. You are gating a form submission on it.

```js
async function validateEmail(email) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 1500);
  try {
    const response = await fetch("https://nobounce.dev/v1/check", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.NOBOUNCE_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email }),
      signal: controller.signal,
    });
    if (!response.ok) return null;
    return await response.json();
  } catch {
    // Timeout, DNS failure, or the service being unreachable. All the same to
    // the caller: we have no opinion about this address.
    return null;
  } finally {
    clearTimeout(timer);
  }
}
```

`null` here means "no information", and the caller must handle it as a normal outcome rather than an exception. Never send the API key in a URL or a query parameter — it belongs in the `Authorization` header, loaded from a secret manager.

Note the error path also covers a non-2xx response. nobounce returns RFC 9457 problem+json with a plain-language `fix` field on every error, which is worth logging, but for the purposes of the signup handler an error is an error and the user proceeds.

## Step 2: branch on the verdict, not on truthiness

There are four verdicts, and only one of them stops anything.

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

// No answer at all. Proceed, and mark the record.
if (result === null) {
  return createAccount({ email, emailVerdict: "unchecked", mxChecked: false });
}

// A correction is available. This is the case that pays for the integration.
if (result.verdict === "undeliverable" && result.suggestion) {
  return respondWithSuggestion(result.suggestion);
}

// Definitively bad, nothing to suggest.
if (result.verdict === "undeliverable") {
  return respondWithError(result.reason);
}

// deliverable, risky and unknown all proceed.
return createAccount({
  email,
  emailVerdict: result.verdict,
  emailReason: result.reason,
  mxChecked: result.checked.mx,
});
```

`risky` proceeds on purpose. It covers role accounts like `admin@` and `contato@`, and disposable-domain addresses. Those are real addresses belonging to real users with real intentions, and rejecting them is a product decision you should make deliberately in your own code rather than inherit from a validator's default.

`unknown` proceeds because it means the check was degraded. Paired with `checked.mx: false` it is the contractual signal that the DNS lookup did not complete. A degraded check is never reported as `deliverable`, which is exactly what makes `unknown` trustworthy as a marker.

## Step 3: store what was checked, not just the verdict

This is the step people skip, and the one that matters six months later.

```sql
ALTER TABLE users ADD COLUMN email_verdict TEXT;
ALTER TABLE users ADD COLUMN email_reason  TEXT;
ALTER TABLE users ADD COLUMN email_mx_checked INTEGER;
```

Three columns. With them, "how many of our users have an address we actually verified against DNS" is a query. Without them, every address in your table looks equally trustworthy, and the ones that arrived during a validator outage are indistinguishable from the ones that passed a full check.

That query is what makes a later cleanup pass possible: re-check the rows where `email_mx_checked = 0` when the validator is healthy, and you recover the coverage you lost during the incident. It also answers the awkward customer-support question directly, with a record rather than a shrug.

## Step 4: make the suggestion the primary path

The correction is the reason to do this at all. `undeliverable` with a `suggestion` is not an error state, it is a conversation:

```
That address looks like a typo. Did you mean diego@gmail.com?
[ Use diego@gmail.com ]   [ Keep diego@gmai.com ]
```

Two things about that UI. The suggestion is one click to accept, because friction here is the whole cost you are trying to avoid. And "keep what I typed" is a real button — confidence is a number between zero and one, not a certainty, and a user who insists on an unusual address should win. Rejecting outright converts a recoverable typo into an abandoned form.

## What not to do

Do not validate on every keystroke. Validate on blur, or on submit. Live DNS checks are metered and a keystroke-triggered validator burns quota on prefixes of addresses that were never real.

Do not put validation in the critical path of a password reset or a login. Those addresses were validated at signup; re-validating them can only lock out an existing user whose provider changed something.

Do not treat `risky` as `undeliverable`. If you want to block disposable domains, branch on `reason === "disposable"` explicitly so the decision is visible in your code and can be changed without touching the validator.

## Verify your integration handles the degraded case

The failure mode you cannot see in normal operation is the one worth testing. Point the client at an unroutable host, or set the timeout to one millisecond, and confirm a signup still completes with `email_mx_checked = 0` recorded. If it returns a 400 to the user instead, the fail-open policy is aspirational rather than implemented.

For evaluating verdict shapes before you have a key, `POST /demo/check` needs no credentials. It resolves a frozen fixture corpus rather than live DNS, so it is a way to see every verdict and reason the API can produce — not a validator you can point production traffic at. Live checks require a paid key, from $1/mo.

The complete integration walkthrough, written for a coding agent to follow step by step, is at [/integrate.md](https://nobounce.dev/integrate.md).
