Below is a complete OTP verification implementation guide for the MERN stack (MongoDB, Express, React, Node.js) using Text.lk SMS.
OTP (One-Time Password) verification is widely used in modern web and mobile applications for:
- Passwordless login
- Mobile number verification
- Secure API access
- Transaction confirmation
In this tutorial, you’ll learn how to build a secure OTP verification system using the MERN stack with Text.lk SMS Gateway.
OTP Verification Flow (MERN)
- User enters mobile number (React)
- Backend generates OTP (Node.js)
- OTP is stored in MongoDB (hashed)
- OTP is sent via Text.lk SMS
- User enters OTP
- OTP is verified and user is authenticated
Project Structure
mern-otp/
├── server/
│ ├── config/
│ │ └── db.js
│ ├── models/
│ │ └── Otp.js
│ ├── routes/
│ │ └── otp.js
│ ├── controllers/
│ │ └── otpController.js
│ ├── utils/
│ │ └── sms.js
│ ├── server.js
│ └── .env
└── client/
├── src/
│ ├── components/
│ │ └── OtpForm.jsx
│ └── App.jsStep 1: Backend Dependencies
cd server
npm init -y
npm install express mongoose bcryptjs dotenv moment cors
npm install textlk-nodeStep 2: Environment Configuration (server/.env)
PORT=5000
MONGO_URI=mongodb://localhost:27017/otp_db
TEXTLK_API_KEY=your_textlk_api_key
TEXTLK_SENDER_ID=TEXTLK
OTP_EXPIRY_MINUTES=5Step 3: MongoDB Connection (config/db.js)
const mongoose = require('mongoose');
const connectDB = async () => {
await mongoose.connect(process.env.MONGO_URI);
console.log('MongoDB connected');
};
module.exports = connectDB;Step 4: OTP Model (models/Otp.js)
const mongoose = require('mongoose');
const otpSchema = new mongoose.Schema({
mobile: { type: String, required: true },
otpHash: { type: String, required: true },
expiresAt: { type: Date, required: true },
isVerified: { type: Boolean, default: false }
}, { timestamps: true });
module.exports = mongoose.model('Otp', otpSchema);Step 5: Text.lk SMS Utility (utils/sms.js)
const TextLK = require('textlk-node');
const sms = new TextLK({
apiKey: process.env.TEXTLK_API_KEY
});
const sendSMS = async (mobile, message) => {
return sms.send({
recipient: mobile,
sender_id: process.env.TEXTLK_SENDER_ID,
type: 'plain',
message
});
};
module.exports = sendSMS;Step 6: OTP Controller (controllers/otpController.js)
Send OTP
const Otp = require('../models/Otp');
const bcrypt = require('bcryptjs');
const moment = require('moment');
const sendSMS = require('../utils/sms');
exports.sendOtp = async (req, res) => {
const { mobile } = req.body;
if (!mobile) {
return res.status(400).json({ message: 'Mobile number required' });
}
const otp = Math.floor(100000 + Math.random() * 900000);
const otpHash = await bcrypt.hash(otp.toString(), 10);
await Otp.create({
mobile,
otpHash,
expiresAt: moment().add(process.env.OTP_EXPIRY_MINUTES, 'minutes')
});
await sendSMS(
mobile,
`Your OTP code is ${otp}. Valid for ${process.env.OTP_EXPIRY_MINUTES} minutes.`
);
res.json({ status: 'success', message: 'OTP sent successfully' });
};Verify OTP
exports.verifyOtp = async (req, res) => {
const { mobile, otp } = req.body;
const record = await Otp.findOne({ mobile, isVerified: false }).sort({ createdAt: -1 });
if (!record) {
return res.status(404).json({ message: 'OTP not found' });
}
if (moment(record.expiresAt).isBefore(moment())) {
return res.status(400).json({ message: 'OTP expired' });
}
const isValid = await bcrypt.compare(otp.toString(), record.otpHash);
if (!isValid) {
return res.status(400).json({ message: 'Invalid OTP' });
}
record.isVerified = true;
await record.save();
res.json({ status: 'success', message: 'OTP verified successfully' });
};Step 7: Routes (routes/otp.js)
const express = require('express');
const router = express.Router();
const otpController = require('../controllers/otpController');
router.post('/send', otpController.sendOtp);
router.post('/verify', otpController.verifyOtp);
module.exports = router;Step 8: Server Bootstrap (server.js)
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
const app = express();
connectDB();
app.use(cors());
app.use(express.json());
app.use('/api/otp', require('./routes/otp'));
app.listen(process.env.PORT, () => {
console.log(`Server running on port ${process.env.PORT}`);
});Step 9: React Frontend Example
OTP Form (OtpForm.jsx)
import { useState } from 'react';
import axios from 'axios';
export default function OtpForm() {
const [mobile, setMobile] = useState('');
const [otp, setOtp] = useState('');
const [step, setStep] = useState(1);
const sendOtp = async () => {
await axios.post('/api/otp/send', { mobile });
setStep(2);
};
const verifyOtp = async () => {
await axios.post('/api/otp/verify', { mobile, otp });
alert('OTP Verified');
};
return (
<div>
{step === 1 && (
<>
<input placeholder="07XXXXXXXX" onChange={e => setMobile(e.target.value)} />
<button onClick={sendOtp}>Send OTP</button>
</>
)}
{step === 2 && (
<>
<input placeholder="Enter OTP" onChange={e => setOtp(e.target.value)} />
<button onClick={verifyOtp}>Verify OTP</button>
</>
)}
</div>
);
}Extra Feature: Resend OTP with Cooldown
const lastOtp = await Otp.findOne({ mobile }).sort({ createdAt: -1 });
if (lastOtp && moment().diff(lastOtp.createdAt, 'seconds') < 60) {
return res.status(429).json({ message: 'Please wait before requesting another OTP' });
}Security Best Practices
✔ Hash OTP before storing
✔ Short expiry (3–5 minutes)
✔ Rate-limit OTP requests
✔ Use HTTPS
✔ Auto-delete expired OTPs
🎯 Use Cases
- Passwordless authentication
- SaaS dashboards
- Mobile app login
- Payment verification
- API security
🏁 Conclusion
You now have a secure, scalable OTP verification system using the MERN stack and Text.lk SMS.
This setup is:
- Modern
- API-ready
- Mobile-friendly
- Enterprise-grade