Build an Email OTP Verification System in Node.js (Step-by-Step)



This content originally appeared on DEV Community and was authored by Jahongir Sobirov

Why we need OTP Verification System?

You’ve built your shiny new app. Users can sign up, everything works… but wait. How do you make sure their email is real? 🤔

Email verification is one of the most important steps in modern authentication systems. Whether it’s for sign-ups, password resets, or extra security, a simple OTP (One-Time Password) can make your app much safer.

In this tutorial, we’ll build a complete email verification system in Node.js using Express.js and MySQL. We’ll create a register & login form, send OTP codes via email, and verify users — step by step 🪄✨.

🛠 2. Project Setup

Before we dive into code, let’s set up a simple Node.js + Express.js + MySQL project.
This will be the base for our register/login + email verification system.

mkdir email-verification-system
cd email-verification-system
npm init -y

This will create a new Node.js project with a default package.json.

📦 Step 2: Install Dependencies

We’ll install the following packages:
express → For building the server
mysql → For connecting to MySQL
body-parser → To handle form data
ejs → For rendering views (register/login pages)
dotenv → For using environment variables
auth-verify → For handle email OTP verification ✨

npm install express mysql2 body-parser ejs dotenv auth-verify

🧱 Step 3: Project Structure

Here’s a clean structure to keep things organized:

│
├── views/             # EJS templates
│   ├── register.ejs
│   ├── login.ejs
│   └── verify.ejs
│
├── .env               # Environment variables
├── index.js          # Entry point
└── package.json

⚡ Step 4: Setup Express Server

Create a file index.js and set up a basic Express server:

// index.js
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');

const app = express();
const PORT = 3000; // Our app is running at localhost:3000

// Middleware
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// EJS setup
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.listen(PORT, () => {
  console.log(`🚀 Server running at http://localhost:${PORT}`);
});

Run the server to check if it works:

node server.js

You should see:

🚀 Server running at http://localhost:3000

🧮 Step 5: Configure MySQL

Connecting MySQL database for storing users

// Create connection
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '',     // your MySQL password
  database: 'auth_demo'
});

// Connect to database
const db = connection.connect((err) => {
  if (err) {
    console.error('❌ MySQL connection error:', err);
    return;
  }
  console.log('✅ Connected to MySQL!');
});

Then open MySQL and create the database:

CREATE DATABASE auth_demo;
USE auth_demo;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  is_verified TINYINT(1) DEFAULT 0
);

🔑 Step 6: Environment Variables

Create a .env file to store your email credentials:

EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_app_password

👉 These will be used by auth-verify internally to send OTP codes securely.

✉ Step 6.1: Getting a Google App Password (Gmail) (🎁 bonus step)

If you’re using Gmail to send verification emails, you can’t use your normal password.
Instead, you need to generate a special App Password from your Google Account.

Follow these steps carefully:

Enable 2-Step Verification

Generate an App Password

  • After enabling 2FA, go back to the Security page
  • Click on “App passwords”
  • Choose:
    • App: Mail
    • Device: Other (Custom name) → enter something like email-verify-app
  • Click Generate

Copy the 16-character password that Google gives you.

abcd efgh ijkl mnop

Paste it into your .env file:

EMAIL_USER=your_email@gmail.com
EMAIL_PASS=abcd efgh ijkl mnop

👉 Don’t include any quotes, and keep the spaces exactly as Google gives them — the auth-verify library and Nodemailer handle this correctly.

💡 Why this is needed:

Google blocks less secure logins for security reasons. App Passwords let trusted apps (like your Node.js server) send emails safely without exposing your real password.

✨ Step 7: Initialize auth-verify

We’ll set up the OTP library inside our index.js file later, but here’s a quick preview:

const Verifier = require('auth-verify');

const verifier = new Verifier({
  sender: process.env.EMAIL_USER,
  pass: process.env.EMAIL_PASS,
  serv: 'gmail', 
  otp: { // otp is optional
    leng: 6, // length of otp code
    expMin: 3, // expiring time of otp mail (in minutes)
    limit: 5, // limit of sending of otp mails (This is needed to prevent being marked as spam) 
    cooldown: 60 // ← user must wait 60 seconds between requests (It’s a small setting that provides a big security and stability boost)
  }
});

🛑 Without Cooldown

  • Imagine a user (or a malicious bot) keeps clicking “Send OTP” repeatedly:
  • Your email service may get flooded with hundreds of emails in seconds.
  • The user’s inbox gets spammed with multiple OTP codes.
  • The system becomes vulnerable to brute force attacks (trying to guess valid OTPs).
  • Your email provider might even block or suspend your account for spam-like behavior.

✅ With Cooldown

  • By setting a cooldown (e.g. 60 seconds), you:
  • Prevent users from spamming the OTP endpoint.
  • Reduce unnecessary email traffic.
  • Make brute force attempts far less effective.
  • Keep your app’s reputation clean with Gmail/SMTP services.

👉 In short, cooldown acts like a rate limiter for OTP requests.

“If you don’t set a cooldown, your ‘Send OTP’ button basically becomes a machine-gun shooting emails at your SMTP server 😅. One bored user = 100 emails per minute = your Gmail account crying.”

✅ At the end of Part 2, we have:

  • A working Express server
  • MySQL database ready
  • EJS templating set up
  • auth-verify library installed and configured

👉 Next up (Part 3): We’ll build the Register & Verify routes using auth-verify to send and check OTP codes, and create the EJS pages for the forms 📝✉

📝 Part 3: Register & Verify Users with OTP

In this part, we’ll build:

  • A simple register form 📝
  • Logic to send OTP to the user’s email ✉
  • A verification page where the user enters the code 🔑
  • Logic to verify the OTP and activate the account ✅

🧭 Step 1: Create Register Page

In views/register.ejs:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Register</title>
</head>
<body>
  <h1>Register</h1>
  <form action="/register" method="POST">
    <input type="email" name="email" placeholder="Email" required /><br><br>
    <input type="password" name="password" placeholder="Password" required /><br><br>
    <button type="submit">Register</button>
  </form>
</body>
</html>

After the user submits this form, we’ll save their info and send an OTP.

// Creating index page 
app.get('/', (req, res)=>{
    res.render('register');
});

And now we’ll get email and password of user for registration when he submits the form:

app.post('/register', (req, res)=>{
    const {email, password} = req.body;

    // Checking data of form
    console.log("Email: ", email);
    console.log("Password: ", password);
});

If it works it should be like this:

🚀 Server running at http://localhost:3000
✅ Connected to MySQL
Email:  youremail@mail.com
Password:  your_password

Checking whether user registered before:

app.post('/register', (req, res)=>{
    const {email, password} = req.body;

    // console.log("Email: ", email);
    // console.log("Password: ", password);

    // Checking if user exists
    db.query(`SELECT * FROM user WHERE email = ?`, [email], (err, result)=>{
        if(err) return res.status(500).send(err);
        if (result.length > 0) return res.send('User already exists');
    });
});

And if user doesn’t registered before we’ll insert this user to our table

app.post('/register', (req, res)=>{
    const {email, password} = req.body;

    // console.log("Email: ", email);
    // console.log("Password: ", password);

    // Checking if user exists
    db.query(`SELECT * FROM user WHERE email = ?`, [email], (err, result)=>{
        if(err) return res.status(500).send(err);
        if (result.length > 0) return res.send('User already exists');

        db.query('INSERT INTO users (email, password, is_verified) VALUES (?, ?, 0)',
            [email, password],
            (err2) => {
                if (err2) return res.send('Error creating user');

                // Send OTP after user is created
                verifier
                .subject("Verify your account: {otp}")
                .text("Your verification code is {otp}")
                .sendTo(email, (err3) => {
                    if (err3) return res.send('Failed to send OTP: ' + err3.message);
                    console.log('✅ OTP sent to', email);
                    res.redirect(`/verify?email=${encodeURIComponent(email)}`);
                });
            }
        );
    });
});

If it works you should get like this result:

[dotenv@17.2.2] injecting env (2) from .env -- tip: 🔐 prevent building .env in docker: https://dotenvx.com/prebuild
🚀 Server running at http://localhost:3000
✅ Connected to MySQL!
✅ OTP sent to jahongir.sobirov.2007@mail.ru

👉 What happens here:

  1. We check if the user already exists.
  2. If not, we insert them into the database with is_verified = 0.
  3. Then we use auth-verify to generate & send an OTP.
  4. Finally, we redirect the user to a verification page.

🧍 Step 3: Verification Page

Create views/verify.ejs:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Verify Email</title>
</head>
<body>
  <h1>Email Verification</h1>
  <form action="/verify" method="POST">
    <input type="hidden" name="email" value="<%= email %>">
    <input type="text" name="otp" placeholder="Enter OTP" required /><br><br>
    <button type="submit">Verify</button>
  </form>
</body>
</html>

🧠 Step 4: Handle OTP Verification

In the same index.js file, add:

app.post('/verify', (req, res) => {
  const { email, otp } = req.body;

  verifier.code(otp).verifyFor(email, (err, isValid) => {
    if (err) return res.send('Error verifying OTP: ' + err.message);

    if (!isValid) {
      return res.send('❌ Invalid or expired OTP. Please try again.');
    }

    // Mark user as verified in DB
    db.query('UPDATE users SET is_verified = 1 WHERE email = ?', [email], (dbErr) => {
      if (dbErr) return res.send('DB error updating verification status');
      res.send('✅ Your email has been successfully verified!');
    });
  });
});

If your OTP is correct:

👉 What this does:

  1. Reads the OTP + email from the form
  2. Uses auth-verify to validate the code
  3. If valid → updates is_verified in the database
  4. Shows a success message 🎉

🧪 Test the Flow

  1. Run the server:
node server.js
  1. Go to http://localhost:3000/register
  2. Fill out the form with a real Gmail address.
  3. Check your inbox for the OTP.
  4. Enter it on the verify page.
  5. See the success message ✅

⚡ What Just Happened

  • When a user registers, your server:
    • Saves their info in MySQL
    • Generates a secure OTP
    • Sends it by email using auth-verify
  • Then the user verifies their code → you mark them as verified.

This is the exact flow real-world apps like Instagram, Gmail, and banking sites use — just simplified for learning. 🧠✨

🔐 Part 4: Login System & Protecting Verified Users

At this stage, we already have:

  • ✅ A register form
  • ✅ OTP email verification with auth-verify
  • ✅ Database storing is_verified status Now we’ll:
  1. Create a login form
  2. Check the user’s credentials
  3. Block login if the user hasn’t verified their email yet 🚫
  4. Show a success page if everything is valid ✅

📝 Step 1: Create Login Page

Create views/login.ejs:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Login</title>
</head>
<body>
  <h1>Login</h1>
  <form action="/login" method="POST">
    <input type="email" name="email" placeholder="Email" required /><br><br>
    <input type="password" name="password" placeholder="Password" required /><br><br>
    <button type="submit">Login</button>
  </form>
</body>
</html>

⚡ Step 2: Add Login Routes

In index.js, add:

// Show login form
app.get('/login', (req, res) => {
  res.render('login');
});

// Handle login logic
app.post('/login', (req, res) => {
  const { email, password } = req.body;

  db.query('SELECT * FROM users WHERE email = ?', [email], (err, results) => {
    if (err) return res.send('Database error');
    if (results.length === 0) return res.send('❌ User not found');

    const user = results[0];

    // Check password
    if (user.password !== password) {
      return res.send('❌ Incorrect password');
    }

    // Check verification status
    if (user.is_verified === 0) {
      return res.send(`
        ⚠ Your email is not verified.<br>
        <a href="/verify?email=${encodeURIComponent(email)}">Verify now</a>
      `);
    }

    // Login success
    res.send(`✅ Welcome back, ${user.email}! You are logged in.`);
  });
});

And our result 😎:

👉 What happens here:

  • We check if the user exists
  • Validate the password
  • If not verified → we show a gentle reminder with a link to verification page
  • If verified → login is successful ✅

🔐 Step 3: Optional — Add Session Support

For a real-world app, you’d normally use sessions or JWT to keep users logged in.
But for this tutorial, a simple “You’re logged in” message is enough.

👉 If you want to extend it:

  • Install express-session
  • Store req.session.user = user after login
  • Create middleware to protect pages

Example (optional):

npm install express-session
// In index.js
const session = require('express-session');

app.use(session({
  secret: 'mysecret',
  resave: false,
  saveUninitialized: true
}));

Then in login:

req.session.user = { id: user.id, email: user.email };
res.redirect('/dashboard');
app.get('/dashboard', (req, res) => {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  res.send(`👋 Welcome ${req.session.user.email}, this is your dashboard!`);
});

🧪 Step 4: Test the Login Flow

1.Run the server:

node index.js
  1. Go to http://localhost:3000/register
  2. → Register a user
  3. Check your email, verify the OTP. ✅
  4. Go to http://localhost:3000/login
  5. → Try logging in.
  6. If you skip verification, you’ll be redirected to verify first. If you verified, you’ll be logged in successfully 🎉

And optional result also:

💽 All codes:

// index.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const mysql = require('mysql');
const Verifier = require('auth-verify');
const session = require('express-session');

const app = express();
const PORT = 3000; // Our app is running at localhost:3000

// Create connection
const db = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '', // your MySQL password
  database: 'auth_demo'
});

// Connect to database
db.connect((err) => {
  if (err) {
    console.error('❌ MySQL connection error:', err);
    return;
  }
  console.log('✅ Connected to MySQL!');
});

// Middleware
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// EJS setup
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

const verifier = new Verifier({
  sender: process.env.EMAIL_USER,
  pass: process.env.EMAIL_PASS,
  serv: 'gmail',
  otp: {
    leng: 6,
    expMin: 3,
    limit: 5,
    cooldown: 60
  }
});

app.use(session({
  secret: 'mysecret',
  resave: false,
  saveUninitialized: true
}));

app.get('/', (req, res)=>{
    res.render('register');
});

app.post('/register', (req, res)=>{
    const {email, password} = req.body;

    // console.log("Email: ", email);
    // console.log("Password: ", password);

    // Checking if user exists
    db.query(`SELECT * FROM users WHERE email = ?`, [email], (err, result)=>{
        if(err) return res.status(500).send(err);
        if (result.length > 0) return res.send('User already exists');

        db.query('INSERT INTO users (email, password, is_verified) VALUES (?, ?, 0)',
            [email, password],
            (err2) => {
                if (err2) return res.send('Error creating user');

                // Send OTP after user is created
                verifier
                    .subject("Verify your account: {otp}")
                    .text("Your verification code is {otp}")
                    .sendTo(email, (err3) => {
                        if (err3) return res.send('Failed to send OTP: ' + err3.message);
                        console.log('✅ OTP sent to', email);
                        res.redirect(`/verify?email=${encodeURIComponent(email)}`);
                    });
            }
        );
    });
});

app.get('/verify', (req, res) => {
  const email = req.query.email;
  res.render('verify', { email });
});

app.post('/verify', (req, res) => {
  const { email, otp } = req.body;

  verifier.code(otp).verifyFor(email, (err, isValid) => {
    if (err) return res.send('Error verifying OTP: ' + err.message);

    if (!isValid) {
      return res.send('❌ Invalid or expired OTP. Please try again.');
    }

    // Mark user as verified in DB
    db.query('UPDATE users SET is_verified = 1 WHERE email = ?', [email], (dbErr) => {
      if (dbErr) return res.send('DB error updating verification status');
      res.send('✅ Your email has been successfully verified!');
    });
  });
});

// Show login form
app.get('/login', (req, res) => {
  res.render('login');
});

// Handle login logic
app.post('/login', (req, res) => {
  const { email, password } = req.body;

  db.query('SELECT * FROM users WHERE email = ?', [email], (err, results) => {
    if (err) return res.send('Database error');
    if (results.length === 0) return res.send('❌ User not found');

    const user = results[0];

    // Check password
    if (user.password !== password) {
      return res.send('❌ Incorrect password');
    }

    // Check verification status
    if (user.is_verified === 0) {
      return res.send(`
        ⚠ Your email is not verified.<br>
        <a href="/verify?email=${encodeURIComponent(email)}">Verify now</a>
      `);
    }

    // Login success
    // res.send(`✅ Welcome back, ${user.email}! You are logged in.`);
    req.session.user = { id: user.id, email: user.email };
    res.redirect('/dashboard')
  });
});

app.get('/dashboard', (req, res) => {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  res.send(`👋 Welcome ${req.session.user.email}, this is your dashboard!`);
});

app.listen(PORT, () => {
  console.log(`🚀 Server running at http://localhost:${PORT}`);
});

⚡ What We Achieved

✅ Email-based verification system using auth-verify
✅ Register → Verify → Login flow
✅ Security checks to block unverified users

This is basically the core skeleton of any modern authentication system — from social apps to fintech products. 🔥

✨ Final Thoughts

  • Using auth-verify made sending & verifying OTP codes super easy.
  • We didn’t need to manually generate codes, manage SQLite, or handle cooldown logic — the library handled it for us.
  • You can easily expand this system by adding:
    • Resend OTP feature
    • Password hashing with bcrypt
    • JWT tokens or OAuth
    • Better error pages & UI

👉 And that’s it — you’ve built a fully working Email Verification System with Node.js, MySQL, and auth-verify 🚀💌


This content originally appeared on DEV Community and was authored by Jahongir Sobirov