DEV Community

Cover image for Build an Email OTP Verification System in Node.js (Step-by-Step)
Jahongir Sobirov
Jahongir Sobirov

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🧱 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
Enter fullscreen mode Exit fullscreen mode

⚡ 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}`);
});
Enter fullscreen mode Exit fullscreen mode

Run the server to check if it works:

node server.js
Enter fullscreen mode Exit fullscreen mode

You should see:

🚀 Server running at http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

🧮 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!');
});

Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

🔑 Step 6: Environment Variables

Create a .env file to store your email credentials:

EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_app_password
Enter fullscreen mode Exit fullscreen mode

👉 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
Enter fullscreen mode Exit fullscreen mode

Paste it into your .env file:

EMAIL_USER=your_email@gmail.com
EMAIL_PASS=abcd efgh ijkl mnop
Enter fullscreen mode Exit fullscreen mode

👉 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)
  }
});
Enter fullscreen mode Exit fullscreen mode

🛑 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>
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

If it works it should be like this:

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

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');
    });
});
Enter fullscreen mode Exit fullscreen mode

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)}`);
                });
            }
        );
    });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

👉 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>
Enter fullscreen mode Exit fullscreen mode

🧠 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!');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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>
Enter fullscreen mode Exit fullscreen mode

⚡ 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.`);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// In index.js
const session = require('express-session');

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

Then in login:

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

🧪 Step 4: Test the Login Flow

1.Run the server:

node index.js
Enter fullscreen mode Exit fullscreen mode
  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}`);
});
Enter fullscreen mode Exit fullscreen mode

⚡ 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 🚀💌

Top comments (0)