← Blog

Validate an email address in an Express API

Syntax with express-validator, MX with the Node DNS module, and disposable/role checks past that — the server-side half of email validation, where your API key actually lives.

Contents

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.

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.

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.

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. Want to eyeball one address before you wire anything? Paste it into the free 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.

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:

{
  "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.

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 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.

Frequently asked questions

How do I validate an email address in an Express API?

In Express, do a quick syntax check in your route handler to reject obvious garbage, then call a validation endpoint for the signals a regex can't give you. Boundstone's POST /v1/verify/email checks syntax, resolves the domain's MX records over DNS, and flags disposable domains, role accounts like info@, and free providers in a single call, returning valid_syntax, mx_found, disposable, role_account, and free_provider. It works on the free tier of 250 credits per month with no card, and every response lists checks.performed and checks.not_performed so you know exactly what was verified.

Does a 'valid' email result mean the address can actually receive mail?

No, and an honest validator should say so up front. A valid result from Boundstone means the address is well-formed and the domain publishes MX records; it does not confirm that a specific mailbox exists or will accept mail, because SMTP mailbox verification and catch-all detection are reported as not_performed. That is the point of the honesty contract: valid proves the address is deliverable-shaped, not that a real inbox sits behind it, so you avoid a false sense of certainty in your signup flow.

Is a regex enough to validate email in Node and Express, or do I need an API?

A regex only confirms an address is syntactically well-formed; it cannot tell you whether the domain can receive mail or whether it is a throwaway address. A validation API adds an MX-record lookup plus disposable-domain, role-account, and free-provider flags, which catch low-quality signups that a regex silently accepts. A practical pattern is to keep a cheap syntax check in your Express handler to filter obvious junk before spending a credit, then call the API for the deeper signals a regex can't see — MX records, disposable domains, and role accounts.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist