Welcome and Email Verification Emails

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"
Note: Always store the hash of the token in MongoDB, not the raw token itself. The raw token is sent to the user’s email. When the user clicks the link, your server receives the raw token, hashes it, and looks up the matching hash in the database. This way, even if your database is compromised, the attacker cannot use the stored hashes to verify accounts โ€” they need the raw tokens that were sent to the email addresses.
Tip: Set a reasonable expiry on verification tokens โ€” 24 hours is standard. Store the expiry timestamp as 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.
Warning: Do not block registration behind email verification. Let users register and receive the JWT immediately, but mark their account as 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;

🧠 Test Yourself

A user clicks the verification link in their email. Your server receives the raw token from the URL, hashes it with SHA-256, then searches for { emailVerifyToken: hashedToken }. The query returns null even though the account exists. What is the most likely cause?