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
- 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
This content originally appeared on DEV Community and was authored by Jahongir Sobirov