DEV Community

Cover image for Building an Email Verification Middleware for Express.js Signup Forms
Arjun CS
Arjun CS

Posted on

Building an Email Verification Middleware for Express.js Signup Forms

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...
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)