Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / May 11, 2026

How to validate an email address in Express

8 minutes read

Qualisend team
An Express code window narrowing an email through syntax, MX, and mailbox validation layers

To validate an email address in Express you wire up middleware, not a single check — and the middleware has to answer three different questions in order. Is the address shaped correctly? Can its domain receive mail? Does the mailbox actually exist? Express itself ships none of these, but the ecosystem around it makes each one a few lines: express-validator for syntax, Node's built-in dns/promises for the domain, and a verification API for the mailbox. This guide builds all three as validators on a single POST route and shows exactly where the framework stops and a real check begins. It's the Express-shaped version of the Node.js email validation guide, so the DNS and SMTP reasoning there carries over intact.

The short answer#

Chain three validators on the email field, cheapest first, and let express-validator's .bail() short-circuit as soon as one is decisive: use body('email').isEmail() for syntax, an async custom validator wrapping dns/promises resolveMx for the domain, and a second async custom validator that calls a verification API for the mailbox — returning a 422 when the verdict is undeliverable. Don't try to open SMTP connections from your Express process to probe mailboxes yourself: outbound port 25 is blocked on most hosts, and the answer depends on sending-IP reputation and greylisting you don't want to reimplement. Each layer rules addresses out more cheaply than the last; only the API can rule one in.

Layer 1: syntax with express-validator#

express-validator is the de-facto validation middleware for Express — a thin, Express-native wrapper around the validator.js library. Its isEmail() check is validator.js's isEmail, so you get the same battle-tested syntax parser every Node project already leans on, exposed as a chainable middleware. Declare a rule on the field and it runs before your handler:

import { body } from "express-validator";

const validateEmail = body("email")
  .trim()
  .isEmail()
  .withMessage("Enter a valid email address.")
  .bail();

.trim() sanitizes leading and trailing whitespace, .isEmail() validates the shape, .withMessage() sets the error text, and .bail() stops the chain for this field the moment syntax fails — so the more expensive layers below never run on a malformed string. That .bail() is the Express equivalent of stopping at the cheapest possible check.

Know exactly what this buys you. isEmail() reads the string — it never resolves DNS and never opens a socket. definitely-fake@gmail.com passes. info@company-that-folded.com passes. typo@gmial.com passes. All three are undeliverable, and no validator that only inspects the string will ever tell you so — the same reason regex email validation fails. Syntax and deliverability are different questions: one is a fact about the string, the other is a fact about the internet.

Layer 2: can the domain receive mail?#

This is the first layer Express doesn't hand you, and it's cheap to bolt on. A domain with no MX records can't accept mail for anyone, so one DNS lookup eliminates dead domains, misspelled company names, and invented TLDs before you spend anything on the network. Node's built-in node:dns/promises resolves MX records with no dependencies, and express-validator's .custom() accepts an async function — throw inside it to fail the field, return true to pass:

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

async function hasMailRoute(email) {
  const domain = email.slice(email.lastIndexOf("@") + 1);

  let records = [];
  try {
    records = await resolveMx(domain);
  } catch {
    // ENOTFOUND / ENODATA — the domain doesn't exist or publishes no MX
    throw new Error("This domain cannot receive email.");
  }

  if (records.length === 0) {
    throw new Error("This domain cannot receive email.");
  }
  return true;
}

Because that's a plain async function, it drops straight into the chain as a custom validator, guarded by another .bail() so the mailbox layer only runs on a domain that actually resolves a mail route:

body("email")
  .isEmail()
  .bail()
  .custom(hasMailRoute)
  .bail();
await hasMailRoute("jane@gmail.com");               // true
await hasMailRoute("jane@company-that-folded.com"); // throws — no MX

Some domains accept mail on an A record with no MX (implicit MX). If you want to honour that edge case, fall back to resolve4 when resolveMx comes back empty — but for the overwhelming majority of real addresses, an MX check is the right filter.

Layer 3: does the mailbox actually exist?#

Layers 1 and 2 can only rule an address out. A domain can publish perfect MX records and still have no mailbox at the address you're holding — noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a mailbox that was never created. Confirming a specific mailbox means the SMTP delivery conversation: connect to the mail host, issue RCPT TO, read the reply, and disconnect before sending anything.

In principle you could script that from Express with the net module. In practice you shouldn't run it from your application server: most cloud hosts block outbound port 25, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit unfamiliar senders — so a probe that works in a local test quietly fails, or gets you blocklisted, in production. How email verification works walks the full pipeline, catch-all domains and all. This is the layer worth delegating.

Calling Qualisend from an async custom validator#

The delegation is just another custom validator. Qualisend's verify endpoint runs the whole pipeline — syntax, DNS, and the SMTP mailbox probe — from reputation-managed infrastructure and returns a verdict. Call it with the global fetch (built into Node 18+), read your key from an environment variable, and throw only when the verdict is undeliverable:

async function isDeliverable(email) {
  const res = await fetch("https://api.qualisend.com/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });

  // On our own outage or a rate limit, don't block a real signup.
  if (!res.ok) return true;

  const { result } = await res.json();

  if (result.status === "undeliverable") {
    throw new Error("We couldn't confirm a mailbox at this address.");
  }
  return true;
}

The endpoint above is a placeholder — check the API reference for the exact base URL and request shape — but the response comes back as a { "result": { ... } } envelope:

{
  "result": {
    "status": "deliverable",
    "score": 95,
    "reason": null,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
  }
}

status is one of deliverable, risky, undeliverable, or unknown. The validator above hard-fails only undeliverable and lets risky and unknown through, so you can decide downstream what to do with them — gate on score, or branch on a sub_flags entry like disposable — instead of turning a borderline address into a form error. Treat the JSON above as the shape, not the contract; the full field list lives in the developer docs.

Putting the layers together to validate an email address in Express#

Compose all three on one body("email") chain, cheapest first, with .bail() between each so a failure short-circuits before the next layer runs. The API validator only fires on an address that already cleared syntax and the MX check:

import { body } from "express-validator";

const validateEmail = [
  body("email")
    .trim()
    .isEmail()
    .withMessage("Enter a valid email address.")
    .bail()
    .custom(hasMailRoute)
    .bail()
    .custom(isDeliverable),
];

Wire that array in front of your route handler, then read the collected errors with validationResult. When the chain fails, respond with a 422 and the error array; otherwise every layer has passed and the address is safe to persist:

import express from "express";
import { validationResult } from "express-validator";

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

app.post("/signup", validateEmail, (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json({ errors: errors.array() });
  }

  // Every layer passed — safe to create the account.
  const { email } = req.body;
  return res.status(201).json({ email });
});

That ordering is the whole trick: isEmail() short-circuits junk before your code runs, the local MX check rules out dead domains for nothing, and the API is hit only for addresses that cleared both. Wrong-order validation — or skipping the cheap layers — spends an API credit on every typo. The layering, not the framework, is what makes validation reliable; it's the same shape as the Node.js version, with Express's middleware chain standing in for the pipeline.

One decision to make up front: what happens when the API call itself fails. A network timeout or a thrown fetch inside isDeliverable shouldn't hand a real customer a 422 they can't fix. The validator above already softens that case by returning true on a non-OK response — treating an unreachable service as unknown rather than undeliverable, letting the signup through and leaving re-verification for later. The syntax and MX layers already ran locally, so you're only relaxing the layer that depends on the network.

Run this fast synchronous pipeline at the point of collection and reserve heavier batch verification for list cleaning. The serverless signup guide shows the real-time pattern end to end, and when you're choosing which service backs layer three, the API comparison lines up the options.

Frequently asked questions#

Is express-validator's isEmail enough to validate an email address?#

For syntax, yes — body("email").isEmail() wraps validator.js's isEmail and is the right layer-one check, far better than a hand-rolled regex. But it validates shape, not deliverability: it never resolves DNS or contacts a mail server, so a pass means "looks like an email," not "will deliver." Pair it with an MX lookup and a mailbox check before you trust the address.

How do I write an async custom validator in Express?#

Pass an async function to express-validator's .custom(). The function receives the field value; return true (or resolve) to pass, and throw an Error (or reject) to fail — the thrown message becomes the validation error. That's how the hasMailRoute and isDeliverable functions above plug into the same body("email") chain. Put a .bail() before each so a failed layer stops the chain instead of running the next one.

Can I check if a mailbox exists in Express without an API?#

Partly. node:dns/promises confirms the domain accepts mail, which rules out dead domains for free and needs no dependency. Confirming the mailbox means an SMTP conversation you can attempt with the net module but shouldn't run from your app server — port 25 is widely blocked and the result depends on your IP's reputation. That's the layer a verification service exists to handle; what email verification is covers the terms.

Should I return a 422 or let the signup through on an unknown result?#

Reject undeliverable with a 422 — it's a confirmed bad address the user can fix. But don't fail on your verification service's own outage: treat a non-OK response, a timeout, or an unknown status as "let it through and re-check later," so a transient network problem never blocks a real registration. The isDeliverable validator does exactly this by returning true when the response isn't OK.


Ready to add the mailbox layer? Drop a syntactically perfect address into the free email checker to watch an isEmail-approved string come back undeliverable, then wire the same verdict into your Express middleware with the copy-paste examples in the API reference.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started