Build a Secure Email Verification System Using Node.js, Express, MongoDB & Nodemailer (Production-Ready Guide)
When you build a user registration system, one of the most important — yet frequently overlooked — security layers is email verification. Without it, your application is vulnerable to a range of issues: fake accounts, spam registrations, undeliverable password reset emails, and even fraud. Email verification is the process of confirming that a user actually owns the email address they registered with, and it remains a foundational step in any production-grade authentication workflow.
Consider what happens without it. A bad actor can register thousands of accounts using fake or temporary email addresses, polluting your database and abusing your services. Your marketing team sends campaigns to addresses that bounce. A legitimate user mistyped their email and can never recover their account. None of these problems are hypothetical — they happen every day on platforms that skip email verification.
Security Benefits at a Glance
- Prevents fake account creation using disposable or non-existent email addresses.
- Reduces spam and bot registrations by adding a verification hurdle.
- Ensures reliable communication by guaranteeing the email in your system is deliverable.
- Strengthens account recovery — password resets and sensitive notifications reach real inboxes.
- Builds user trust by demonstrating your platform follows standard security practices.
Real-World Use Cases
Every major platform — from GitHub and Stripe to Google and Amazon — requires email verification before granting full account access. SaaS platforms use it to validate paying customers. E-commerce sites use it to secure order confirmations. Banking and fintech applications make it mandatory before any financial action can occur. In this guide, you will implement this same production-ready workflow for your own Node.js application.
Note: This is not a toy implementation. Every decision in this guide reflects real-world production standards, including hashed token storage, expiry enforcement, rate limiting, and account enumeration prevention.
What We Will Build
By the end of this tutorial, you will have a fully functional, secure email verification system built on Node.js and Express.js, backed by MongoDB, with emails delivered via Nodemailer. Here is what the system includes:
- User registration with password hashing via
bcryptjs - Cryptographically secure token generation using Node.js's built-in
cryptomodule - Hashed token storage in MongoDB — the raw token is never persisted
- Transactional email delivery via Nodemailer with SMTP
- A verification route that validates the token, checks expiry, and activates the account
- Login protection that blocks unverified users
- A resend verification email endpoint
- Input validation with
express-validator - Rate limiting with
express-rate-limit - Account enumeration prevention
The Verification Workflow
- User submits the registration form with name, email, and password.
- Server validates the input, hashes the password, generates a secure token, stores the hashed token, and saves the user with
isVerified: false. - Server sends a verification email containing a unique link with the raw token as a URL parameter.
- User clicks the link in their email client.
- Server hashes the incoming token, finds the matching user record, checks that the token has not expired, sets
isVerified: true, and clears the token fields. - User can now log in successfully.
Prerequisites
Before you begin, make sure you have the following installed and configured on your development machine:
- Node.js v18 or higher (nodejs.org)
- npm v9 or higher (bundled with Node.js)
- MongoDB — either a local installation or a free cloud cluster via MongoDB Atlas
- A working SMTP email account — Gmail with an App Password, or a transactional email service such as Mailgun, SendGrid, or Mailtrap (for testing)
- Familiarity with basic JavaScript, async/await, and REST API concepts
- A code editor such as VS Code
- Postman or Insomnia for API testing
Tip: For development and testing, use Mailtrap. It provides a safe inbox sandbox that captures emails without delivering them to real recipients. This prevents accidental emails during development.
Project Setup
Open your terminal and run the following commands to initialise the project:
mkdir email-verification-app
cd email-verification-app
npm init -y
This creates a new directory for your project and initialises a package.json file with default values. The -y flag accepts all defaults automatically.
Installing Dependencies
Install all production dependencies in one command:
npm install express mongoose bcryptjs nodemailer express-validator express-rate-limit dotenv
Install development dependencies:
npm install --save-dev nodemon
Here is what each package does:
| Package | Version | Purpose |
|---|---|---|
express |
^4.x | Web framework for building REST APIs |
mongoose |
^8.x | MongoDB object modelling (ODM) |
bcryptjs |
^2.x | Password hashing using the bcrypt algorithm |
nodemailer |
^6.x | Sending emails via SMTP or other transports |
express-validator |
^7.x | Middleware-based request input validation |
express-rate-limit |
^7.x | Rate limiting for Express routes |
dotenv |
^16.x | Loading environment variables from a .env file |
nodemon |
^3.x | Auto-restarts server during development on file changes |
Update your package.json scripts section:
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
}
Folder Structure
A well-organised folder structure is not just good practice — it is the difference between a project that scales and one that collapses under its own complexity. Use the following professional architecture:
email-verification-app/
├── config/
│ └── db.js # MongoDB connection logic
├── controllers/
│ └── auth.controller.js # Business logic for auth routes
├── middleware/
│ ├── rateLimiter.js # Rate limiting configurations
│ └── validate.js # Input validation middleware
├── models/
│ └── User.js # Mongoose User schema and model
├── routes/
│ └── auth.routes.js # Express route definitions
├── utils/
│ ├── generateToken.js # Secure token generation helper
│ └── sendEmail.js # Nodemailer email sending utility
├── .env # Environment variables (never commit this)
├── .env.example # Safe template for collaborators
├── .gitignore # Ignore node_modules and .env
├── app.js # Express application entry point
└── package.json
Create the directories now:
mkdir config controllers middleware models routes utils
Environment Variables Setup
Create a .env file in the project root. This file stores sensitive credentials and should never be committed to source control.
# .env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/email-verification-app
# Email Configuration
EMAIL_HOST=smtp.mailtrap.io
EMAIL_PORT=587
EMAIL_USER=your_mailtrap_username
EMAIL_PASS=your_mailtrap_password
EMAIL_FROM_NAME=MyApp
EMAIL_FROM_ADDRESS=noreply@myapp.com
# Application URL (used in email links)
APP_URL=http://localhost:5000
Also create a .env.example file as a safe documentation template for other developers on your team:
# .env.example
PORT=5000
MONGODB_URI=your_mongodb_connection_string
EMAIL_HOST=your_smtp_host
EMAIL_PORT=587
EMAIL_USER=your_smtp_username
EMAIL_PASS=your_smtp_password
EMAIL_FROM_NAME=YourAppName
EMAIL_FROM_ADDRESS=noreply@yourapp.com
APP_URL=https://yourapp.com
Add the following to your .gitignore:
node_modules/
.env
MongoDB Connection Setup
Create the file config/db.js. This module handles connecting to MongoDB and exits the process cleanly if the connection fails — the right behaviour for a production server rather than silently continuing in a broken state.
// config/db.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`MongoDB Connection Error: ${error.message}`);
process.exit(1); // Exit process with failure code
}
};
module.exports = connectDB;
The useNewUrlParser and useUnifiedTopology options avoid deprecation warnings and use the newer, more robust Mongoose connection handling internals. Calling process.exit(1) on failure is intentional: a server without a database connection is not useful and should not pretend otherwise.
User Model Creation with Mongoose
Create models/User.js. This is the heart of the system. The schema captures all the fields needed for the full registration and verification lifecycle.
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters'],
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
'Please enter a valid email address',
],
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false, // Never returned in queries by default
},
isVerified: {
type: Boolean,
default: false,
},
verificationToken: {
type: String,
select: false, // Sensitive — excluded from queries by default
},
verificationTokenExpires: {
type: Date,
select: false,
},
lastLogin: {
type: Date,
},
},
{
timestamps: true, // Adds createdAt and updatedAt automatically
}
);
// -------------------------------------------------------
// Pre-save hook: Hash password before persisting to DB
// -------------------------------------------------------
userSchema.pre('save', async function (next) {
// Only hash the password if it has been modified (or is new)
if (!this.isModified('password')) return next();
try {
// Salt rounds: 12 is a good balance of security and performance
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// -------------------------------------------------------
// Instance method: Compare a plain-text password to the hash
// -------------------------------------------------------
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
const User = mongoose.model('User', userSchema);
module.exports = User;
Key Schema Design Decisions Explained
select: false on sensitive fields: Applying select: false to password, verificationToken, and verificationTokenExpires means these fields are excluded from all query results by default. You must explicitly request them with .select('+fieldName') when you actually need them. This prevents sensitive data from leaking into API responses accidentally.
Pre-save hook for password hashing: The isModified('password') check is critical. Without it, the password would be re-hashed every time the document is saved for any reason — resetting the verification token, updating the profile, or anything else — rendering the password permanently invalid.
Salt rounds of 12: bcrypt's cost factor controls how computationally expensive hashing is. The value 12 means 212 = 4,096 iterations. It takes roughly 250ms on modern hardware — fast enough for user-facing operations but slow enough to make brute-force attacks impractical. OWASP recommends a minimum of 10; 12 is a sensible production default.
Security Note: Never store plain-text passwords. Never store reversibly encrypted passwords. One-way hashing with bcrypt is the correct approach, and bcrypt's built-in salt prevents rainbow table attacks even if your database is compromised.
Generating Secure Verification Tokens
Create utils/generateToken.js. This utility encapsulates the secure token generation logic. Understanding why we hash the token before storing it is important, so read the explanation below carefully.
// utils/generateToken.js
const crypto = require('crypto');
/**
* Generates a cryptographically secure random verification token.
*
* Strategy:
* - rawToken: sent to the user in the email link (never stored)
* - hashedToken: stored in MongoDB (never exposed to the user)
*
* This means if an attacker reads your database, they cannot use
* the stored hash to forge verification links.
*
* @returns {{ rawToken: string, hashedToken: string, expires: Date }}
*/
const generateVerificationToken = () => {
// 32 random bytes = 256 bits of entropy, hex-encoded to 64 characters
const rawToken = crypto.randomBytes(32).toString('hex');
// Hash the raw token with SHA-256 before database storage
const hashedToken = crypto
.createHash('sha256')
.update(rawToken)
.digest('hex');
// Token is valid for 24 hours from the moment of creation
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
return { rawToken, hashedToken, expires };
};
module.exports = { generateVerificationToken };
JWT vs Random Tokens: Which Should You Use?
This is a common question. For email verification specifically, a cryptographically random token (as implemented here) is generally preferable to a JWT for the following reasons:
| Aspect | Random Token (SHA-256 hashed) | JWT |
|---|---|---|
| Storage required | Yes — hashed token stored in DB | No — self-contained and stateless |
| Revocable before expiry | Yes — delete or overwrite in DB | No — valid until expiry unless blocklisted |
| Database read on verification | Yes | No (but needed to mark as verified) |
| Token length in email link | 64 characters (hex) | 150–300+ characters |
| Invalidated after use | Yes — removed from DB after use | Requires extra logic or blocklist |
| Complexity | Low | Higher |
For a one-time, short-lived operation like email verification, the stateful random-token approach is cleaner and more secure. You can immediately invalidate a token by deleting it from the database, which is impossible with a pure JWT without maintaining a blocklist.
Configure Nodemailer for Email Delivery
Create utils/sendEmail.js. Nodemailer is the most widely used Node.js library for sending emails and supports every major SMTP provider.
// utils/sendEmail.js
const nodemailer = require('nodemailer');
/**
* Creates and returns a configured Nodemailer transporter.
* The transporter is created per-call rather than as a module-level
* singleton to allow for easier testing and environment switching.
*/
const createTransporter = () => {
return nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT, 10),
// Secure (SSL) on port 465; STARTTLS on 587
secure: parseInt(process.env.EMAIL_PORT, 10) === 465,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
};
/**
* Sends an email verification link to a newly registered user.
*
* @param {string} to - Recipient email address
* @param {string} name - Recipient display name
* @param {string} rawToken - Plain-text token (appended to the verification URL)
*/
const sendVerificationEmail = async (to, name, rawToken) => {
const transporter = createTransporter();
const verificationUrl = `${process.env.APP_URL}/api/auth/verify-email/${rawToken}`;
const mailOptions = {
from: `"${process.env.EMAIL_FROM_NAME}" <${process.env.EMAIL_FROM_ADDRESS}>`,
to,
subject: 'Please Verify Your Email Address',
html: `
<div style="max-width:600px; margin:0 auto; font-family:Arial,sans-serif; color:#333;">
<h2>Welcome to ${process.env.EMAIL_FROM_NAME}, ${name}!</h2>
<p>Thank you for creating an account. To activate it, please verify your email address by clicking the button below.</p>
<p style="text-align:center; margin:32px 0;">
<a href="${verificationUrl}"
style="background:#2563eb; color:#fff; padding:14px 28px; text-decoration:none;
border-radius:6px; font-weight:bold; display:inline-block;">
Verify My Email Address
</a>
</p>
<p>If the button does not work, copy and paste this URL into your browser:</p>
<p style="word-break:break-all;"><a href="${verificationUrl}">${verificationUrl}</a></p>
<hr>
<p style="font-size:13px; color:#666;">
This link expires in <strong>24 hours</strong>. If you did not create this account,
you can safely ignore this email.
</p>
</div>
`,
};
await transporter.sendMail(mailOptions);
};
/**
* Sends a new verification link when the user requests a resend.
* Reuses the same template as the initial registration email.
*/
const sendResendVerificationEmail = async (to, name, rawToken) => {
await sendVerificationEmail(to, name, rawToken);
};
module.exports = { sendVerificationEmail, sendResendVerificationEmail };
Gmail Users: If you use Gmail as your SMTP provider, you must generate an App Password from your Google Account security settings. Never use your main Gmail password in your application. Navigate to Google Account → Security → 2-Step Verification → App passwords to generate one.
Input Validation Middleware
Create middleware/validate.js. Never trust user input. Every piece of data that arrives from a client must be validated and sanitised before it touches your business logic or database.
// middleware/validate.js
const { body, validationResult } = require('express-validator');
/**
* Centralised handler that sends a 422 response if any validation errors exist.
* Placed at the end of each validation chain array.
*/
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
};
// Validation rules for user registration
const validateRegistration = [
body('name')
.trim()
.notEmpty().withMessage('Name is required.')
.isLength({ min: 2, max: 50 }).withMessage('Name must be between 2 and 50 characters.'),
body('email')
.trim()
.notEmpty().withMessage('Email address is required.')
.isEmail().withMessage('Please provide a valid email address.')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required.')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters long.')
.matches(/(?=.*[a-z])/).withMessage('Password must contain at least one lowercase letter.')
.matches(/(?=.*[A-Z])/).withMessage('Password must contain at least one uppercase letter.')
.matches(/(?=.*\d)/).withMessage('Password must contain at least one number.'),
handleValidationErrors,
];
// Validation rules for login
const validateLogin = [
body('email')
.trim()
.notEmpty().withMessage('Email address is required.')
.isEmail().withMessage('Invalid email format.')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required.'),
handleValidationErrors,
];
// Validation rules for resend-verification (email only)
const validateEmail = [
body('email')
.trim()
.notEmpty().withMessage('Email address is required.')
.isEmail().withMessage('Invalid email format.')
.normalizeEmail(),
handleValidationErrors,
];
module.exports = { validateRegistration, validateLogin, validateEmail };
Rate Limiting Middleware
Create middleware/rateLimiter.js. Rate limiting is your first line of defence against brute-force attacks and automated credential stuffing. Without it, an attacker can attempt thousands of passwords against a login endpoint in seconds.
// middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
/**
* Global limiter applied to all routes.
* 100 requests per 15 minutes per IP address.
*/
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false, // Disable the deprecated `X-RateLimit-*` headers
message: {
success: false,
message: 'Too many requests from this IP. Please try again in 15 minutes.',
},
});
/**
* Strict limiter for authentication routes (register, login).
* 10 requests per 15 minutes per IP.
*/
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many authentication attempts. Please try again after 15 minutes.',
},
});
/**
* Conservative limiter for the resend-verification endpoint.
* 3 requests per hour per IP to prevent email flooding.
*/
const resendLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many resend requests. Please wait an hour before trying again.',
},
});
module.exports = { globalLimiter, authLimiter, resendLimiter };
Authentication Controller: Registration, Verification, Login, and Resend
Create controllers/auth.controller.js. This is where all the business logic lives. Each function is a self-contained handler for one specific operation.
// controllers/auth.controller.js
const crypto = require('crypto');
const User = require('../models/User');
const { generateVerificationToken } = require('../utils/generateToken');
const { sendVerificationEmail, sendResendVerificationEmail } = require('../utils/sendEmail');
// ================================================================
// REGISTER
// POST /api/auth/register
// ================================================================
const register = async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if this email is already registered
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser) {
/*
* ACCOUNT ENUMERATION PREVENTION:
* Do not reveal whether this email is already in the database.
* Attackers use registration forms to enumerate valid email addresses.
* Return a generic success-like response regardless.
*/
return res.status(200).json({
success: true,
message:
'If this email is not already registered, a verification link has been sent to your inbox.',
});
}
// Generate a secure verification token
const { rawToken, hashedToken, expires } = generateVerificationToken();
// Create the user — password is hashed by the pre-save hook in the model
await User.create({
name: name.trim(),
email: email.toLowerCase(),
password,
verificationToken: hashedToken, // Store hashed token — never the raw one
verificationTokenExpires: expires,
});
// Send the verification email with the raw (unhashed) token in the URL
await sendVerificationEmail(email.toLowerCase(), name.trim(), rawToken);
return res.status(201).json({
success: true,
message:
'Registration successful. Please check your email and click the verification link to activate your account.',
});
} catch (error) {
console.error('[register]', error);
return res.status(500).json({
success: false,
message: 'Registration failed. Please try again later.',
});
}
};
// ================================================================
// VERIFY EMAIL
// GET /api/auth/verify-email/:token
// ================================================================
const verifyEmail = async (req, res) => {
try {
const { token } = req.params;
if (!token) {
return res.status(400).json({ success: false, message: 'Verification token is missing.' });
}
// Hash the incoming raw token to find the matching stored hash
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
// Find a user whose hashed token matches AND whose token has not expired
const user = await User.findOne({
verificationToken: hashedToken,
verificationTokenExpires: { $gt: Date.now() },
}).select('+verificationToken +verificationTokenExpires');
if (!user) {
return res.status(400).json({
success: false,
message:
'This verification link is invalid or has expired. Please request a new one.',
});
}
// Activate the account and clear the verification token fields
user.isVerified = true;
user.verificationToken = undefined;
user.verificationTokenExpires = undefined;
await user.save({ validateBeforeSave: false });
return res.status(200).json({
success: true,
message: 'Your email address has been verified. You can now log in.',
});
} catch (error) {
console.error('[verifyEmail]', error);
return res.status(500).json({
success: false,
message: 'Email verification failed. Please try again.',
});
}
};
// ================================================================
// LOGIN
// POST /api/auth/login
// ================================================================
const login = async (req, res) => {
try {
const { email, password } = req.body;
// Fetch the user and explicitly include the password field
const user = await User.findOne({ email: email.toLowerCase() }).select('+password');
/*
* Intentionally run comparePassword even if the user is not found.
* This prevents timing attacks that can detect valid vs invalid emails
* by measuring response time differences.
*/
const dummyHash =
'$2a$12$KIXKOxzxgjvMaQJY1P/4xu5jD4MXu9J9h2b8QW/vAZyq7sHqmvGEy';
const passwordMatch = user
? await user.comparePassword(password)
: await require('bcryptjs').compare(password, dummyHash);
if (!user || !passwordMatch) {
return res.status(401).json({
success: false,
message: 'Invalid email or password.',
});
}
// Block login if the email has not been verified
if (!user.isVerified) {
return res.status(403).json({
success: false,
message:
'Your email address has not been verified. Please check your inbox or request a new verification email.',
code: 'EMAIL_NOT_VERIFIED',
});
}
// Record the last login timestamp
user.lastLogin = new Date();
await user.save({ validateBeforeSave: false });
/*
* In a full implementation, you would generate and return a JWT here.
* For this guide, we return the user object to confirm successful login.
* Never return the password field.
*/
return res.status(200).json({
success: true,
message: 'Login successful.',
data: {
id: user._id,
name: user.name,
email: user.email,
isVerified: user.isVerified,
lastLogin: user.lastLogin,
},
});
} catch (error) {
console.error('[login]', error);
return res.status(500).json({
success: false,
message: 'Login failed. Please try again.',
});
}
};
// ================================================================
// RESEND VERIFICATION EMAIL
// POST /api/auth/resend-verification
// ================================================================
const resendVerification = async (req, res) => {
try {
const { email } = req.body;
// Generic response to prevent email enumeration
const genericResponse = {
success: true,
message:
'If your email is registered and unverified, a new verification link has been sent to your inbox.',
};
const user = await User.findOne({ email: email.toLowerCase() }).select(
'+verificationToken +verificationTokenExpires'
);
// Return the generic response if user not found or already verified
if (!user || user.isVerified) {
return res.status(200).json(genericResponse);
}
// Generate a fresh token
const { rawToken, hashedToken, expires } = generateVerificationToken();
user.verificationToken = hashedToken;
user.verificationTokenExpires = expires;
await user.save({ validateBeforeSave: false });
await sendResendVerificationEmail(user.email, user.name, rawToken);
return res.status(200).json(genericResponse);
} catch (error) {
console.error('[resendVerification]', error);
return res.status(500).json({
success: false,
message: 'Failed to resend verification email. Please try again later.',
});
}
};
module.exports = { register, verifyEmail, login, resendVerification };
Timing Attack Prevention: In the login handler above, we always execute the password comparison — even when the user is not found — using a dummy bcrypt hash. This ensures the response time is consistent regardless of whether the email exists, making it impossible for an attacker to detect valid emails by timing the server's responses.
Express Routes
Create routes/auth.routes.js. This file wires middleware and controller functions to HTTP methods and URL paths.
// routes/auth.routes.js
const express = require('express');
const router = express.Router();
const {
register,
verifyEmail,
login,
resendVerification,
} = require('../controllers/auth.controller');
const {
validateRegistration,
validateLogin,
validateEmail,
} = require('../middleware/validate');
const { authLimiter, resendLimiter } = require('../middleware/rateLimiter');
// Register a new user account
router.post('/register', authLimiter, validateRegistration, register);
// Verify email address via the link sent in the registration email
router.get('/verify-email/:token', verifyEmail);
// Log in to an existing verified account
router.post('/login', authLimiter, validateLogin, login);
// Resend the verification email
router.post('/resend-verification', resendLimiter, validateEmail, resendVerification);
module.exports = router;
Application Entry Point
Create app.js in the project root. This ties everything together.
// app.js
require('dotenv').config(); // Must be called before any other require that reads env vars
const express = require('express');
const connectDB = require('./config/db');
const authRoutes = require('./routes/auth.routes');
const { globalLimiter } = require('./middleware/rateLimiter');
const app = express();
// -------------------------------------------------------
// Connect to MongoDB
// -------------------------------------------------------
connectDB();
// -------------------------------------------------------
// Built-in Middleware
// -------------------------------------------------------
app.use(express.json({ limit: '10kb' })); // Limit request body size
app.use(express.urlencoded({ extended: false }));
// -------------------------------------------------------
// Global Rate Limiter
// -------------------------------------------------------
app.use(globalLimiter);
// -------------------------------------------------------
// Routes
// -------------------------------------------------------
app.use('/api/auth', authRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
// -------------------------------------------------------
// 404 Handler
// -------------------------------------------------------
app.use((req, res) => {
res.status(404).json({ success: false, message: 'Requested route not found.' });
});
// -------------------------------------------------------
// Global Error Handler
// -------------------------------------------------------
app.use((err, req, res, next) => {
console.error('[Global Error]', err.stack);
res.status(err.statusCode || 500).json({
success: false,
message: err.message || 'An unexpected error occurred.',
});
});
// -------------------------------------------------------
// Start Server
// -------------------------------------------------------
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});
module.exports = app;
Security Best Practices
Implementing the features is only part of the job. Understanding the security reasoning behind each decision is what separates a professional from an amateur. Here is a comprehensive breakdown of the security principles applied in this guide.
1. Token Expiration (24-Hour Window)
Verification tokens should have a limited lifespan. A 24-hour window is the industry standard — long enough to be user-friendly across time zones, short enough to minimise the window of exposure if an email is compromised. In higher-security applications (fintech, healthcare), consider reducing this to one hour.
2. Hashed Token Storage
The raw token is never stored. Only its SHA-256 hash is persisted in MongoDB. This is the same principle behind password hashing — even if your database is fully compromised, an attacker cannot use the stored hashes to generate valid verification links. Only the user who received the email has the raw token.
3. Rate Limiting
Three separate rate limiters are applied: a global limiter, a strict auth-route limiter (10 requests per 15 minutes), and a conservative resend limiter (3 requests per hour). Without rate limiting, attackers can brute-force passwords, flood inboxes, or enumerate valid accounts at machine speed.
4. Input Validation and Sanitisation
All user input is validated via express-validator before it reaches the controller. normalizeEmail() canonicalises email addresses to prevent duplicate registrations via format variations (e.g., uppercase vs lowercase, Gmail dot tricks). Request body size is capped at 10KB in the Express middleware configuration.
5. Account Enumeration Prevention
Both the register and resend-verification endpoints return identical responses regardless of whether the email exists. This prevents an attacker from discovering which email addresses are registered by observing differences in server responses. This is a subtle but important defence against targeted attacks.
6. Password Strength Enforcement
Passwords are required to be at least 8 characters and must contain uppercase, lowercase, and numeric characters. This enforces a minimum entropy baseline. In higher-security applications, also require at least one special character.
7. Timing Attack Resistance
The login handler always runs the bcrypt comparison, even when the user does not exist. Without this, an attacker can determine which email addresses are registered by measuring the difference in server response time (bcrypt is intentionally slow, so the absence of a comparison would be measurably faster).
8. Environment Variable Management
All secrets — database URIs, SMTP credentials, application URLs — are stored in .env files that are excluded from source control via .gitignore. A .env.example file documents the required variables without exposing their values.
9. HTTPS in Production
Always deploy behind HTTPS in production. Verification links sent over plain HTTP can be intercepted in transit. Use a TLS certificate from Let's Encrypt (free) or your cloud provider. When deploying on platforms like Heroku, Render, or Vercel, HTTPS is handled automatically.
10. Use a Dedicated Email Sending Service
For production, avoid relying on your personal Gmail account. Use a transactional email service with proper SPF, DKIM, and DMARC records configured. Services like SendGrid, Mailgun, Amazon SES, and Postmark ensure high deliverability, detailed send analytics, and dedicated IP reputation management.
Common Errors and Solutions
| Error Message / Symptom | Likely Cause | Solution |
|---|---|---|
MongooseServerSelectionError |
MongoDB is not running or the URI is wrong | Start your local MongoDB service or verify your Atlas connection string in .env |
Error: Invalid login (Nodemailer) |
Incorrect SMTP credentials | Double-check EMAIL_USER and EMAIL_PASS; for Gmail, use an App Password, not your main password |
| Verification link returns "Invalid or expired" | Token mismatch or expired token | Ensure you are passing rawToken (not the hash) in the email URL; check system clock skew |
| Password re-hashed on every save | Missing isModified('password') check in pre-save hook |
Add the if (!this.isModified('password')) return next(); guard to the pre-save hook |
Cannot read properties of null on login |
User not found and comparePassword called on null |
Check if (!user || !passwordMatch) pattern; see timing-attack-safe login implementation above |
ValidationError: email: Path email is required |
Email field missing from request body | Ensure Content-Type header is application/json and express.json() middleware is applied |
| Rate limiter blocking legitimate requests | Threshold too low or IP shared (e.g., NAT) | Adjust max values; consider user-ID-based limiting in addition to IP-based for authenticated routes |
| Email marked as spam | Missing SPF/DKIM records or using a shared IP | Configure SPF, DKIM, and DMARC DNS records; use a reputable transactional email provider in production |
SyntaxError: Unexpected token in JSON |
Request body is malformed JSON | Validate JSON in Postman/Insomnia; check that all property names and strings are double-quoted |
| Token not found after save | select: false on token fields |
Use .select('+verificationToken +verificationTokenExpires') when querying for the token fields |
Testing the System Step by Step
Start the development server first:
npm run dev
You should see output similar to:
Server running in development mode on port 5000
MongoDB Connected: localhost
Use Postman, Insomnia, or curl to test each endpoint in order:
Step 1: Register a New User
POST http://localhost:5000/api/auth/register
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com",
"password": "SecurePass1"
}
Expected response:
{
"success": true,
"message": "Registration successful. Please check your email and click the verification link..."
}
Step 2: Check Your Email Inbox
If using Mailtrap, log in to your Mailtrap inbox and open the verification email. Copy the verification URL from the email body.
Step 3: Attempt Login Before Verification
POST http://localhost:5000/api/auth/login
Content-Type: application/json
{
"email": "jane@example.com",
"password": "SecurePass1"
}
Expected response (403 Forbidden):
{
"success": false,
"message": "Your email address has not been verified...",
"code": "EMAIL_NOT_VERIFIED"
}
Step 4: Verify the Email
GET http://localhost:5000/api/auth/verify-email/<rawToken>
Expected response:
{
"success": true,
"message": "Your email address has been verified. You can now log in."
}
Step 5: Login After Verification
POST http://localhost:5000/api/auth/login
Content-Type: application/json
{
"email": "jane@example.com",
"password": "SecurePass1"
}
Expected response (200 OK):
{
"success": true,
"message": "Login successful.",
"data": {
"id": "...",
"name": "Jane Doe",
"email": "jane@example.com",
"isVerified": true,
"lastLogin": "2025-06-10T..."
}
}
Step 6: Test the Resend Endpoint
POST http://localhost:5000/api/auth/resend-verification
Content-Type: application/json
{
"email": "jane@example.com"
}
Since Jane is already verified, you should receive the generic response — confirming that your account enumeration prevention is working correctly.
Step 7: Test Rate Limiting
Send more than 10 POST requests to /api/auth/login within 15 minutes from the same IP to verify the rate limiter blocks subsequent requests with a 429 status code.
Real-World Email Verification Workflow
The diagram below illustrates the complete flow from registration through to a successful first login after email verification:
CLIENT SERVER DATABASE / EMAIL
| | |
|--- POST /api/auth/register -->| |
| { name, email, password } | |
| |-- Validate input ----------------> |
| |-- Hash password (bcrypt) --------> |
| |-- Generate rawToken + hashedToken |
| |-- Save user (isVerified: false) -->| MongoDB
| |-- Send email (rawToken in URL) --->| SMTP Server
|<-- 201 { success: true } -----| |
| | |
| [User opens email and clicks verification link] |
| | |
|--- GET /verify-email/:token -->| |
| |-- Hash incoming token (SHA-256) |
| |-- Find user by hashedToken ------->| MongoDB
| |-- Check expiry ($gt: Date.now()) |
| |-- Set isVerified: true >| MongoDB
| |-- Clear token fields >| MongoDB
|<-- 200 { success: true } -----| |
| | |
|--- POST /api/auth/login ----->| |
| { email, password } | |
| |-- Find user by email ------------> | MongoDB
| |-- bcrypt.compare(password, hash) |
| |-- Check isVerified === true |
| |-- Update lastLogin --------------> | MongoDB
|<-- 200 { success: true } -----| |
| | |
Production Deployment Considerations
Moving a Node.js application from development to production involves several important configuration and infrastructure decisions.
Set NODE_ENV to Production
Always set NODE_ENV=production in your production environment. Many libraries, including Express, behave differently in production mode — error messages become less verbose (reducing information disclosure), and certain development-only features are disabled.
Use a Process Manager
Use PM2 to manage your Node.js process in production. It restarts the application automatically on crashes, handles log rotation, and supports clustering to utilise multiple CPU cores:
npm install -g pm2
pm2 start app.js --name email-verification-app -i max
pm2 save
pm2 startup
Use Environment-Specific .env Files
Maintain separate configuration for development, staging, and production environments. Never reuse the same SMTP credentials or database connection string across environments.
Configure a Reverse Proxy
Place Nginx or Apache in front of your Node.js application. A reverse proxy handles TLS termination, static file serving, compression, and distributes load — all tasks the application server should not handle directly.
Transactional Email Service
Replace your SMTP configuration with a dedicated transactional email provider in production. SendGrid, Mailgun, Amazon SES, and Postmark all provide Node.js SDKs and superior deliverability over a personal email account.
MongoDB Atlas for Managed Hosting
Use MongoDB Atlas for production database hosting. It provides automated backups, monitoring, scaling, and does not require you to manage infrastructure. Enable IP allowlisting and use connection strings with authentication.
Implement Structured Logging
Replace console.log calls with a structured logging library such as winston or pino. Structured logs are machine-parseable, integrate with log aggregation services like Datadog or Elasticsearch, and support log levels (info, warn, error) properly.
Database Indexing
The email field has a unique: true constraint in Mongoose, which automatically creates a MongoDB index. Also add an index on verificationToken to make token lookups fast:
userSchema.index({ verificationToken: 1 });
userSchema.index({ verificationTokenExpires: 1 }, { expireAfterSeconds: 0 });
The TTL (Time To Live) index on verificationTokenExpires automatically removes documents where the expiry has passed — useful if you want MongoDB to auto-clean expired, unverified users after a set period.
Performance Optimization Tips
Decouple Email Sending with a Job Queue
Sending an email synchronously during a registration request adds latency and creates a single point of failure. If the SMTP server is slow or temporarily unavailable, the registration endpoint hangs or fails. In production, use a job queue such as Bull (backed by Redis) or BullMQ to process email sending asynchronously. The registration endpoint enqueues the job and returns immediately; the queue worker sends the email in the background.
Connection Pooling
Mongoose maintains a connection pool to MongoDB. The default pool size is 5. For high-traffic applications, increase this via the maxPoolSize option:
mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 20,
});
Cache Frequently Read Data
User profile data that is read on every authenticated request (after login) is a prime candidate for in-memory caching via node-cache or a Redis cache. This dramatically reduces MongoDB read operations under load.
Reuse the Nodemailer Transporter
Creating a new SMTP connection for every email is expensive. In a production application with a job queue, create the transporter once as a module-level singleton and reuse it across all email sending calls. When using a job queue worker, initialise the transporter when the worker starts.
Frequently Asked Questions
1. Why is email verification important for my Node.js application?
Email verification confirms that users genuinely own the email addresses they register with. Without it, your application is open to fake registrations, spam, and abuse. It also ensures that password reset emails and critical notifications actually reach the intended recipient. For any application that stores personal data or handles financial transactions, email verification is a legal and ethical baseline.
2. Should I use JWT or a random token for email verification?
For email verification specifically, a cryptographically random token (stored as a hash in the database) is the better choice. JWTs are stateless, which makes them difficult to revoke without maintaining a blocklist. A random token can be invalidated instantly by deleting or overwriting it in the database. JWTs are better suited for session management and API authentication where statefulness is a disadvantage.
3. How long should a verification link remain valid?
Twenty-four hours is the widely accepted standard and is used in this guide. It accommodates users across different time zones and those who do not check their email immediately. For higher-security applications such as fintech or healthcare platforms, you might reduce this to one hour. Always let users request a new token if theirs expires.
4. Can users resend the verification email if they lose it?
Yes. This guide includes a dedicated POST /api/auth/resend-verification endpoint. When called, it generates a new token, overwrites the old one in the database (immediately invalidating it), and sends a fresh verification email. The endpoint is rate-limited to 3 requests per hour to prevent abuse.
5. What happens when a verification token expires?
The verifyEmail controller queries for the user using both the hashed token and a condition that the expiry date is in the future ($gt: Date.now()). If the token has expired, no user record is found, and the server returns a 400 response instructing the user to request a new link. The expired token remains in the database until the user requests a resend or a TTL index cleans it up.
6. How do large companies implement email verification?
At scale, companies like GitHub, Stripe, and Google use the same fundamental approach — a secure, short-lived token sent via email — but augmented with more sophisticated infrastructure. They use dedicated email sending services (Amazon SES, SendGrid) with warm IP pools and strict deliverability monitoring. Email sending is always decoupled from the request cycle via a job queue. They also implement fallback channels (SMS) and integrate fraud detection signals such as disposable email domain detection.
7. What is account enumeration and why does it matter?
Account enumeration is when an attacker can determine whether a specific email address is registered on your platform by observing differences in server responses. For example, if your register endpoint returns "Email already exists" when the address is taken, an attacker can systematically test millions of addresses. This guide prevents enumeration by returning identical responses whether the email exists or not.
8. Should I delete unverified users after some time?
It is good practice to periodically clean up unverified accounts that were never activated. You can do this with a scheduled job (using node-cron) or a MongoDB TTL index on the verificationTokenExpires field. For example, deleting users where isVerified is false and createdAt is older than 7 days keeps your database tidy and reduces storage costs.
9. Can I verify emails in the background without affecting registration speed?
Yes, and in high-traffic production systems, you should. Use a message queue such as BullMQ with Redis. The registration endpoint creates the user, enqueues an email-sending job, and returns a 201 response immediately. A separate queue worker process picks up the job and sends the email asynchronously. This removes email sending as a latency factor and a failure point in the registration request.
10. How do I handle the case where the email service is down during registration?
In the current synchronous implementation, an SMTP failure causes the registration endpoint to return a 500 error — even though the user was already saved. The cleaner approach is to wrap email sending in a try/catch independently from user creation, log the failure, and queue a retry. With a job queue, retries are built in. At minimum, log the failure and expose the resend endpoint so users can trigger a new email once the service recovers.
11. Is bcrypt the best algorithm for password hashing?
bcrypt is battle-tested and widely recommended for password hashing. Argon2 (specifically Argon2id) is a newer algorithm that won the Password Hashing Competition and offers better resistance to GPU-based attacks due to its configurable memory-hardness. For new applications with strict security requirements, Argon2id is worth evaluating. The argon2 npm package is the standard Node.js implementation. For most applications, bcrypt with a cost factor of 12 remains an excellent and pragmatic choice.
12. Do I need to verify emails for OAuth (Google, GitHub) sign-ins?
No. When a user signs in with a third-party OAuth provider, the email address has already been verified by that provider. You should not send a verification email for OAuth registrations. Set isVerified: true immediately for users created through an OAuth flow, since the OAuth provider guarantees that the user owns and has access to that email address.
Final Thoughts
You have now built a complete, production-ready email verification system using Node.js, Express, MongoDB, and Nodemailer. More importantly, you understand the security reasoning behind every architectural decision — from why tokens are hashed before storage, to why timing attacks need to be mitigated at the login layer, to why account enumeration prevention matters even in a verification endpoint.
This system is a foundation, not a ceiling. Natural next steps for extending it include:
- Integrating full JWT-based authentication so that verified users receive a signed token on login.
- Adding a password reset flow — which follows the exact same cryptographic token pattern as verification.
- Decoupling email sending with a Redis-backed job queue using BullMQ.
- Integrating a disposable email detection library to block throwaway email domains at registration.
- Adding two-factor authentication (TOTP) as an optional second layer after email verification.
- Replacing
console.logwith a structured logger likepino.
Security is not a feature — it is a practice. The patterns you have applied in this guide (hashed storage, expiry enforcement, rate limiting, timing-safe comparisons, account enumeration prevention) are the same patterns used in production systems that handle millions of users. Carry them forward into every authentication system you build.
The complete source code for this guide follows the folder structure defined above. Ensure your
.envvariables are correctly set before running the application. Use Mailtrap for development and switch to a production-grade SMTP provider before going live.

Join the conversation