# Validate an email address in an Express API

_2026-10-09 · Boundstone (https://boundstone.io/blog/validate-email-express)_


You want to validate email in Express before you write a row to your `users` table, and you have found that Express itself validates nothing — it hands you `req.body.email` as a string and wishes you luck. The good news: the pieces you need are one small npm package and one function from the Node standard library. The honest news: neither proves the address can receive mail, and only a step past them tells you whether it's a throwaway. Here is the server-side stack, in order, ending at a `POST /signup` handler that decides who gets in.

This is the server side on purpose. If you also validate in a React form, that check is a courtesy for the user — it runs in a browser anyone can open the devtools on. The real decision, and any API key, belongs here in Express.

## Syntax is the part every framework does

Almost every validator you have used checks the *shape* of an address: a local part, an `@`, a domain with a dot. Django's `EmailValidator` does exactly this and no more. Rails' `URI::MailTo::EMAIL_REGEXP` does the same. So does `express-validator`. That check is genuinely useful and, for plenty of apps, genuinely all you need — say so and move on.

What syntax validation cannot tell you: that `alex@gmial.com` is a typo, that `sales@` is a shared inbox nobody reads, or that the domain was registered an hour ago to farm your free tier. Start with syntax because it's free and instant. Don't mistake it for a verdict.

## express-validator handles the syntax

express-validator wraps validator.js, so `body("email").isEmail()` runs the same rules-plus-regex check you'd get from `validator.isEmail`. It is syntax only. It does not touch DNS.

```javascript
import express from "express";
import { body, validationResult } from "express-validator";

const app = express();
app.use(express.json());

app.post(
  "/signup",
  body("email").isEmail().withMessage("Enter a valid email address"),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ error: "invalid_syntax" });
    }
    res.status(201).json({ ok: true });
  }
);
```

If a well-formed address is enough for your case, stop here. Most signup flows want at least the next step.

## dns.promises.resolveMx checks the domain can receive mail

Syntax says the string is shaped right. It says nothing about whether anyone runs a mail server at that domain. `resolveMx` from `node:dns/promises` answers that — it looks up the MX records and either returns them or throws.

```javascript
import { resolveMx } from "node:dns/promises";

async function hasMx(domain) {
  try {
    const records = await resolveMx(domain);
    return records.length > 0;
  } catch (err) {
    // ENOTFOUND / ENODATA => no mail server published for this domain
    if (err.code === "ENOTFOUND" || err.code === "ENODATA") return false;
    throw err; // real DNS failure — don't swallow it as "invalid"
  }
}
```

Wire the two together as one middleware and you have a self-contained, no-third-party check: syntax, then domain-can-receive-mail.

```javascript
const validateEmail = [
  body("email").isEmail(),
  async (req, res, next) => {
    if (!validationResult(req).isEmpty()) {
      return res.status(400).json({ error: "invalid_syntax" });
    }
    const domain = req.body.email.split("@")[1];
    if (!(await hasMx(domain))) {
      return res.status(400).json({ error: "no_mail_server" });
    }
    next();
  },
];
```

For many apps this is the finish line, and it costs you nothing but a DNS lookup. If you're doing the same thing outside Express — a plain script, a worker, a different framework — the two steps port directly; see [validate an email address in Node.js](/blog/validate-email-nodejs). Want to eyeball one address before you wire anything? Paste it into the free [email validator](/tools/email-validator) — no signup, same checks.

## The layer past that: disposable and role addresses

MX tells you `mailinator.com` accepts mail. It does not tell you `mailinator.com` is disposable, or that `admin@yourcustomer.com` is a distribution list rather than a person. Those are list-comparison problems, and keeping the lists current is the tedious part. That's the layer Boundstone adds over syntax and MX — in one call, with one response shape across every language you call it from.

```javascript
async function verifyEmail(email) {
  const r = await fetch("https://api.boundstone.io/v1/verify/email", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BOUNDSTONE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });
  if (!r.ok) throw new Error(`boundstone ${r.status}`);
  return r.json();
}
```

A valid result looks like this:

```json
{
  "valid_syntax": true,
  "domain": "example.com",
  "mx_found": true,
  "disposable": false,
  "role_account": false,
  "free_provider": false,
  "checks": {
    "performed": ["syntax", "mx", "disposable_list", "role_list"],
    "not_performed": ["smtp_mailbox", "catch_all"]
  }
}
```

Read `checks.not_performed` before you trust `valid_syntax`. Boundstone does **not** open an SMTP connection to the mailbox, and it does **not** probe for catch-all domains — both are listed there. So a clean response never means "this inbox exists." It means the address is well-formed, the domain accepts mail, and it isn't a known disposable or role address. To prove the mailbox, send a confirmation email. The `not_performed` list is the point: it tells you exactly what a "valid" does and doesn't cover, so you don't over-trust it. (`free_provider` is returned too — a gmail.com flag, useful for your own scoring — but it isn't one of the checks in `performed`.)

## A POST /signup handler, end to end

Now the route. The middleware already rejected bad syntax; since Boundstone returns `mx_found`, you can lean on that instead of a second local DNS call, or keep `hasMx` as a zero-latency pre-filter so obviously dead domains never spend a credit.

```javascript
app.post("/signup", validateEmail, async (req, res, next) => {
  try {
    const result = await verifyEmail(req.body.email);

    if (!result.mx_found) {
      return res.status(400).json({ error: "no_mail_server" });
    }
    if (result.disposable) {
      return res.status(400).json({ error: "disposable_address" });
    }
    if (result.role_account) {
      return res.status(400).json({ error: "role_address" }); // sales@, admin@, ...
    }

    // Passed every check we ran. We have NOT proven the mailbox exists
    // (smtp_mailbox + catch_all are not_performed), so confirm by email.
    await createPendingUser(req.body.email);
    res.status(201).json({ ok: true, next: "check_your_inbox" });
  } catch (err) {
    next(err); // DNS failure or a 5xx from the API is not the user's fault
  }
});
```

`process.env.BOUNDSTONE_KEY` is why this is a server-side post. Keep the `bs_live_` key in Express and out of any browser bundle. Disposable and role flags are two signals; if you're fighting organized abuse rather than typos, they combine with phone and IP checks — [multi-signal signup fraud](/blog/multi-signal-signup-fraud) covers wiring several together.

## The short version

- **Syntax:** `express-validator`'s `.isEmail()` (validator.js under the hood). Syntax only — no DNS. Same class of check as Django's `EmailValidator` and Rails' regex.
- **Domain accepts mail:** `resolveMx` from `node:dns/promises`. Free, in the stdlib, catches dead and mistyped domains. For many apps, syntax + MX is the whole job.
- **Disposable / role, one contract:** Boundstone's `/v1/verify/email` adds `disposable`, `role_account`, and `free_provider` on top of syntax and MX — with `checks.not_performed` spelling out what it skipped.
- **What none of them prove:** that the mailbox exists. No SMTP probe, no catch-all detection — both are `not_performed`. Send a confirmation email for that, every time.
