Signup forms are one of those things that look simple until you actually run one in production. You end up scattering the same email-checking logic across every route that touches user creation — signup, invite-a-friend, admin-adds-a-user — and eventually one of those routes forgets the check entirely.
The fix is boring but effective: pull the validation into middleware, run it once, and let every route that needs it just plug it in.
Here's how I set it up.
Step 1: The Problem with Inline Validation
A typical first pass looks like this, copy-pasted into every route that creates a user:
app.post('/signup', async (req, res) => {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
// create user...
});
This works until you have a second route. Then a third. Then someone adds a new "invite teammate" endpoint six months later and forgets the check entirely, because it's not enforced anywhere central — it's just a convention everyone has to remember.
Step 2: Pulling It Out into Middleware
Express middleware is the natural fix here. Define it once, attach it to any route that needs it:
// middleware/verifyEmail.js
function isValidFormat(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
async function verifyEmailMiddleware(req, res, next) {
const { email } = req.body;
if (!email || !isValidFormat(email)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
next();
}
module.exports = verifyEmailMiddleware;
Now every route that needs email validation just declares it:
const verifyEmailMiddleware = require('./middleware/verifyEmail');
app.post('/signup', verifyEmailMiddleware, async (req, res) => {
// by the time we get here, email format is already confirmed
res.status(200).json({ message: 'Signed up successfully' });
});
app.post('/invite', verifyEmailMiddleware, async (req, res) => {
// same guarantee, no copy-pasted logic
res.status(200).json({ message: 'Invite sent' });
});
One place to update the rule, every route inherits it. No route can silently skip the check.
Step 3: Leveling It Up Past Format Checks
Format-only middleware stops garbage input, but it won't stop someone signing up with test@test.com or a domain that doesn't accept mail. To catch that, the middleware needs to do an async lookup before calling next():
const dns = require('dns').promises;
async function verifyEmailMiddleware(req, res, next) {
const { email } = req.body;
if (!email || !isValidFormat(email)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
const domain = email.split('@')[1];
try {
const mxRecords = await dns.resolveMx(domain);
if (mxRecords.length === 0) {
return res.status(400).json({ error: 'This domain cannot receive email' });
}
} catch {
return res.status(400).json({ error: 'This domain cannot receive email' });
}
next();
}
This is the same MX-lookup approach I covered in my last post on Node.js email validation — it plugs neatly into the middleware pattern here.
Step 4: Handling Timeouts and Failures Gracefully
One thing that bit me early on: DNS lookups can hang or fail for reasons that have nothing to do with the user's email being bad (network blip, DNS server timeout). If your middleware treats every failure as "invalid email," you'll reject real users.
async function verifyEmailMiddleware(req, res, next) {
const { email } = req.body;
if (!email || !isValidFormat(email)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
const domain = email.split('@')[1];
try {
const mxRecords = await dns.resolveMx(domain);
if (mxRecords.length === 0) {
return res.status(400).json({ error: 'This domain cannot receive email' });
}
req.emailChecked = true;
next();
} catch (err) {
console.error('Email verification lookup failed:', err.message);
// fail open rather than blocking a legitimate signup on a DNS hiccup
req.emailChecked = false;
next();
}
}
Whether you fail open or closed here is a judgment call — I lean toward failing open for signup forms since blocking a real user over a transient DNS issue is worse than letting one borderline case through.
Step 5: Swapping in a Proper Verification Service
Once you need catch-all detection, disposable-domain filtering, or mailbox-level checks, the DNS-only version above starts running into the same ceiling I hit in my last article. I ended up replacing the MX-lookup block with a call to Gamalogic's verification API, keeping the exact same middleware shape:
async function verifyEmailMiddleware(req, res, next) {
const { email } = req.body;
if (!email || !isValidFormat(email)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
try {
const result = await validateWithAPI(email); // returns { status: 'valid' | 'invalid' | 'catch-all' }
if (result.status === 'invalid') {
return res.status(400).json({ error: 'This email address doesn\'t look deliverable' });
}
next();
} catch (err) {
console.error('Verification service error:', err.message);
next(); // fail open, same reasoning as before
}
}
Same interface, same routes, no changes anywhere else in the app — just a swapped-out implementation behind the middleware.
Takeaway
Middleware turns "remember to validate the email on every route" into "the route just can't run without it." Start with format + MX checks if you're prototyping, decide deliberately whether to fail open or closed on lookup errors, and swap in a dedicated verification step once DIY checks stop covering your edge cases.
Top comments (0)