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'sEmailValidatorand Rails' regex. - Domain accepts mail:
resolveMxfromnode: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/emailaddsdisposable,role_account, andfree_provideron top of syntax and MX — withchecks.not_performedspelling 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.