Email verification is a critical trust mechanism โ it confirms that the user controls the email address they registered with, preventing others from registering with someone else’s email. The flow is: generate a secure random token on the server, hash it and store the hash in MongoDB, send an email to the user containing the raw token as part of a verification URL, and build an Express endpoint that verifies the token and marks the account as verified. In this lesson you will implement the complete email verification flow for the MERN Blog registration process.
The Email Verification Flow
1. User registers โ POST /api/auth/register
2. Server: generate 32-byte random token
3. Server: store SHA-256(token) in user.emailVerifyToken + set expiry 24h
4. Server: send email with link: CLIENT_URL/verify-email?token=RAW_TOKEN
5. Server: respond with 201 (user created, verification email sent)
โ
(user clicks link in email)
โ
6. React: extracts token from URL query string
7. React: sends GET /api/auth/verify-email?token=RAW_TOKEN
8. Server: hashes received token โ looks up user with matching hash
9. Server: checks token not expired
10. Server: marks isEmailVerified: true, clears token fields
11. Server: responds 200 (verified)
12. React: shows "Account verified โ you can now log in"
emailVerifyExpires: Date in the User model and check it server-side: user.emailVerifyExpires > Date.now(). If the token is expired, return a 400 error with a message asking the user to request a new verification email. Build a resend verification endpoint (POST /api/auth/resend-verification) for this case.isEmailVerified: false. Restrict certain actions (e.g. publishing a post or leaving comments) to verified accounts only. Blocking login entirely until verification creates a terrible UX โ users who mistype their email or whose email is delayed are locked out completely.User Schema โ Verification Fields
// server/src/models/User.js โ add these fields to the schema
const userSchema = new mongoose.Schema({
// ... existing fields ...
isEmailVerified: {
type: Boolean,
default: false,
},
emailVerifyToken: {
type: String,
select: false, // never return in queries by default
},
emailVerifyExpires: {
type: Date,
select: false,
},
});
Generating and Sending the Verification Token
// server/src/controllers/authController.js
const crypto = require('crypto');
const sendEmail = require('../utils/sendEmail');
const register = asyncHandler(async (req, res) => {
const { name, email, password } = req.body;
const existing = await User.exists({ email });
if (existing) throw new AppError('Email already registered', 409);
// Create the user
const user = await User.create({ name, email, password });
// โโ Generate verification token โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const rawToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Store the HASH + expiry (not the raw token)
await User.findByIdAndUpdate(user._id, {
emailVerifyToken: hashedToken,
emailVerifyExpires: expiry,
});
// โโ Build the verification URL (contains the RAW token) โโโโโโโโโโโโโโโโโโโ
const verifyUrl = `${process.env.CLIENT_URL}/verify-email?token=${rawToken}`;
// โโ Respond immediately โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const token = signAccessToken(user._id, user.role);
res.status(201).json({
success: true,
token,
message: 'Account created. Please check your email to verify your account.',
data: { _id: user._id, name: user.name, email: user.email, role: user.role,
isEmailVerified: false },
});
// โโ Send verification email after responding (fire-and-forget) โโโโโโโโโโโโ
sendEmail({
to: user.email,
subject: 'Verify your MERN Blog email address',
text: `Hi ${user.name},\n\nVerify your email by visiting:\n${verifyUrl}\n\nThis link expires in 24 hours.`,
html: `
<h2>Welcome to MERN Blog, ${user.name}!</h2>
<p>Please verify your email address by clicking the button below.</p>
<a href="${verifyUrl}" style="background:#3b82f6;color:white;padding:12px 24px;
text-decoration:none;border-radius:6px;display:inline-block;">
Verify Email Address
</a>
<p>This link expires in 24 hours. If you did not create this account, ignore this email.</p>
`,
}).catch(err => console.error('Verification email failed:', err.message));
});
The Verify Email Endpoint
// @desc Verify email with token from URL
// @route GET /api/auth/verify-email?token=RAW_TOKEN
// @access Public
const verifyEmail = asyncHandler(async (req, res) => {
const { token } = req.query;
if (!token) throw new AppError('Verification token is required', 400);
// Hash the received token to match what is stored in MongoDB
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
// Find user by hashed token โ include fields that are select: false
const user = await User.findOne({
emailVerifyToken: hashedToken,
emailVerifyExpires: { $gt: Date.now() }, // token not expired
}).select('+emailVerifyToken +emailVerifyExpires');
if (!user) {
throw new AppError('Verification link is invalid or has expired', 400);
}
// Mark as verified and clear the token
user.isEmailVerified = true;
user.emailVerifyToken = undefined;
user.emailVerifyExpires = undefined;
await user.save({ validateBeforeSave: false });
res.json({
success: true,
message: 'Email verified successfully. You can now use all features.',
});
});
// server/src/routes/auth.js
router.get('/verify-email', verifyEmail);
React โ Verification Page
// src/pages/VerifyEmailPage.jsx
import { useState, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import api from '@/services/api';
import Spinner from '@/components/ui/Spinner';
function VerifyEmailPage() {
const [searchParams] = useSearchParams();
const [status, setStatus] = useState('loading'); // 'loading' | 'success' | 'error'
const [message, setMessage] = useState('');
useEffect(() => {
const token = searchParams.get('token');
if (!token) { setStatus('error'); setMessage('No verification token found.'); return; }
api.get(`/auth/verify-email?token=${token}`)
.then(() => { setStatus('success'); setMessage('Your email has been verified!'); })
.catch(err => {
setStatus('error');
setMessage(err.response?.data?.message || 'Verification failed.');
});
}, [searchParams]);
if (status === 'loading') return <Spinner message="Verifying your email..." />;
return (
<div className="auth-page">
{status === 'success' ? (
<>
<h1>โ
Email Verified</h1>
<p>{message}</p>
<Link to="/dashboard" className="btn btn--primary">Go to Dashboard</Link>
</>
) : (
<>
<h1>โ Verification Failed</h1>
<p>{message}</p>
<Link to="/resend-verification" className="btn btn--secondary">
Request a new link
</Link>
</>
)}
</div>
);
}
Common Mistakes
Mistake 1 โ Using $gt: Date.now() without the select override
โ Wrong โ token fields not returned because they have select: false:
const user = await User.findOne({ emailVerifyToken: hashedToken });
// Returns null! emailVerifyToken has select: false โ not included in query by default
โ Correct โ explicitly include the fields needed for the lookup:
const user = await User.findOne({ emailVerifyToken: hashedToken })
.select('+emailVerifyToken +emailVerifyExpires'); // โ
Mistake 2 โ Sending the verification URL with the hashed token
โ Wrong โ hash in the email link, raw token stored in DB:
const verifyUrl = `${CLIENT_URL}/verify-email?token=${hashedToken}`;
// User clicks link with hash โ server hashes it again โ double-hashed โ no match!
โ Correct โ raw token in email URL, hash stored in DB:
const verifyUrl = `${CLIENT_URL}/verify-email?token=${rawToken}`; // โ raw in URL
// DB stores hash; server hashes received raw token to compare
Mistake 3 โ Blocking login until email is verified
โ Wrong โ users cannot log in until they verify:
if (!user.isEmailVerified) throw new AppError('Please verify your email first', 403);
// Mistyped email or delayed email = permanently locked out
โ Correct โ allow login, restrict specific features:
// In createPost controller:
if (!req.user.isEmailVerified) throw new AppError('Please verify your email to publish', 403);
// Users can log in; publishing is restricted until verified โ
Quick Reference
| Task | Code |
|---|---|
| Generate token | crypto.randomBytes(32).toString('hex') |
| Hash token | crypto.createHash('sha256').update(rawToken).digest('hex') |
| Store in DB | user.emailVerifyToken = hashedToken |
| Send in email | `${CLIENT_URL}/verify-email?token=${rawToken}` |
| Verify server-side | User.findOne({ emailVerifyToken: hash, emailVerifyExpires: { $gt: Date.now() } }) |
| Mark verified | user.isEmailVerified = true; user.emailVerifyToken = undefined; |