Email Verification in MERN Stack Using JWT and Nodemailer (Step-by-Step Guide)

Intro:

When building user authentication in any real-world web application, verifying the user's email is a critical step. It not only ensures data validity but also helps prevent fake signups and increases trust.

In this guide, I'll walk you through how I implemented email verification in my MERN stack project using JWT and Nodemailer, without using Firebase or external auth providers.

NOTE: Here I have only discussed about the backend part.

Stack used:

  • MongoDB (Mongoose)

  • Express.js

  • React (Frontend – not covered in this article)

  • Node.js

  • JWT for token generation

  • Nodemailer for sending emails

  • Gmail App Password for SMTP access

Step-by-Step Implementation (Backend):

  1. Add isVerified to User Schema.

    Suppose while creating user schema, also add this field as well.

    
     import mongoose from "mongoose";
    
     const userSchema = new mongoose.Schema({
    
       firstName: {
         type: String, 
         required: true,
         lowercase: true,
         trim: true,
         minLength: 1,
         maxLength: 20,
       },
    
       lastName: {
         type: String,
         required: true,
         lowercase: true,
         trim: true,
         minLength: 1,
         maxLength: 20,
       },
    
       emailId: {
         type: String,
         required: true,
         lowercase: true,
         trim: true,
         unique: true,
       },
    
       password: {
         type: String,
         required: true,
       },
    
       // This field I am talking about
       isVerified: {
         type: Boolean,
         default: false,
       }
    
     }, {timestamps: true});
    
     export const User = mongoose.model("User", userSchema);
    

    This isVerified field helps track whether the user has confirmed their email.

  2. Setup Nodemailer with Gmail.

    I used Nodemailer with a Gmail account. Since Gmail doesn’t allow basic login from code anymore, I created an App Password from Google.

    For creating App Password, just go to your gmail account → go to security → enable 2F authentication (if not enabled) → search for “App Passwords“ → an inputbox will come, enter any name like “nodemailer“ or “anything“ → a Google app password will be generated → copy it and save it.

    Create `.env` file and save data:

     MONGODB_URI=yourdatabaseurl
     PORT=7000
     JWT_SECRET_KEY=givestronglargekey
     MAIL_USER=youremailid@gmail.com // give your gmail from which you want to send the mail to users
     MAIL_PASS=abcdefghijlkm // you generated app password without space
     DOMAIN_NAME=https://yourapp.com
    

    After this create `sendEmail.js` for sending verification email:

     import nodemailer from "nodemailer";
    
     export const sendEmail = async ({ to, subject, html }) => {
       const transporter = nodemailer.createTransport({
         service: "gmail",
         auth: {
           user: process.env.MAIL_USER,
           pass: process.env.MAIL_PASS,
         },
       });
    
       await transporter.sendMail({
         from: `"Your Name / Your product name" <${process.env.MAIL_USER}>`,
         to,
         subject,
         html,
    
         // This has been used as by default mail goes to spam folder. To prevent I have used this
         headers: {
           "X-Priority": "1 (Highest)",
           "X-MSMail-Priority": "High",
           Importance: "High",
         },
       });
     };
    
  3. Generate a JWT Token on Signup.

    On successful signup, I generated a short-lived JWT token containing the user’s ID:

     import { User } from "../models/user.models.js";
     import bcrypt from "bcrypt";
     import validation from "../utils/validation.js";
     import validator from "validator";
     import jwt from "jsonwebtoken";
     import { sendEmail } from "../utils/sendEmail.js";
    
     export const signupUser = async (req, res) => {
       try {
         // Validating the input data
         validation(req);
    
         const { firstName, lastName, emailId, password } = req.body;
         const hashPassword = await bcrypt.hash(password, 10); // hashing password
    
         // Storing data into database
         const user = await User.create({
           firstName,
           lastName,
           emailId,
           password: hashPassword,
         });
    
         // Generating JWT token
         const token = await jwt.sign(
           { _id: user._id },
           process.env.JWT_SECRET_KEY,
           { expiresIn: "10m" }
         );
    
         // Created a link, clicking which user will get verified
         const link = `${process.env.DOMAIN_NAME}/verify-email?token=${token}`;
    
         // Sending mail
         await sendEmail({
           to: user.emailId,
           subject: "Email Verification",
           html: `<p>Hello ${user.firstName},</p>
                   <p>Please verify your email to complete your signup at YourReview.</p>
                   <p><a href="${link}">Click here to verify your email</a></p>
                   <p>Thanks,<br/>The Team</p>
                 `,
         });
    
         return res
           .status(201)
           .json({ success: true, message: "Email verification sent!", user });
       } catch (error) {
         return res
           .status(500)
           .json({
             success: false,
             message: "Oops! Something went wrong.",
             ERROR: error.message,
           });
       }
     };
    
  4. Verification Route:

    When the user clicks the verification link from the email, the request is sent directly to the backend verification endpoint. The backend extracts the token from the query string, verifies it, and updates the user's isVerified flag in the database.

     import { User } from "../models/user.models.js";
     import jwt from "jsonwebtoken";
    
     export const verifyEmail = async (req, res) => {
       try {
         const {token} = req.query;
    
         const decodedData = await jwt.verify(token, process.env.JWT_SECRET_KEY);
         const {_id} = decodedData;
    
         const user = await User.findOne({_id});
         if(!user) {
           throw new Error("User not found.");
         }
    
         if(user.isVerified) {
           return res.status(200).json({message: "User already verified"});
         }
    
         user.isVerified = true;
         await user.save();
    
         return res.status(200).json({success: true, message: "Email verified successfully!"});
    
       } catch (error) {
         return res.status(400).json({success: false, message: "Invalid or expired token."});
       }
     }
    
  5. Create routing for `/verify-email`:

    
     import express from 'express';
     import { verifyEmail } from '../controllers/verifyEmail.controller.js';
    
     const authRouter = express.Router();
    
     authRouter.get("/verify-email", verifyEmail);
    
     export default authRouter;
    
  6. Block Login if Email Not Verified:

    To prevent unverified users from logging in, I added a simple check in the login controller:

if (!user.isVerified) {
  return res.status(403).json({ message: "Please verify your email first." });
}

Conclusion

Email verification is a core feature for any secure web app, and implementing it yourself gives you full control and learning. Using JWT with Nodemailer, sending emails becomes easy and customizable.

If you're building a MERN app and want to ensure only real users get through — adding email verification like this is 100% worth it.

Lastly, if you find this helpful then do like, comment and share.

0
Subscribe to my newsletter

Read articles from Bishal Kumar Shaw directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bishal Kumar Shaw
Bishal Kumar Shaw