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
- Go to https://myaccount.google.com/security
- Find the “Signing in to Google” section
- Turn on 2-Step Verification (if it’s not already on)
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 likeemail-verify-app
-
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:
- We check if the user already exists.
- If not, we insert them into the database with is_verified = 0.
- Then we use auth-verify to generate & send an OTP.
- 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:
- Reads the OTP + email from the form
- Uses auth-verify to validate the code
- If valid → updates is_verified in the database
- Shows a success message 🎉
🧪 Test the Flow
- Run the server:
node server.js
- Go to http://localhost:3000/register
- Fill out the form with a real Gmail address.
- Check your inbox for the OTP.
- Enter it on the verify page.
- 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:
- Create a login form
- Check the user’s credentials
- Block login if the user hasn’t verified their email yet 🚫
- 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
- Go to http://localhost:3000/register
- → Register a user
- Check your email, verify the OTP. ✅
- Go to http://localhost:3000/login
- → Try logging in.
- 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 🚀💌
Top comments (0)