Introduction
Picture this: You've just launched your new app, and within 24 hours, someone has already hijacked three user accounts. Static passwords alone aren't cutting it anymore. One-time passwords (OTPs) offer that extra security layer that keeps accounts safe even when credentials leak.
OTP authentication has become the standard for securing everything from banking apps to food delivery services. If you're building any application that handles sensitive user data or transactions, you need a solid OTP implementation.
This guide walks you through building a complete OTP authentication system in Node.js, covering everything from generating secure codes to handling SMS delivery failures. You'll learn the security pitfalls that trip up most developers, understand when to choose SMS over email, and see working code you can adapt for your own projects.
Table of Contents
- Understanding OTP Authentication Fundamentals
- Setting Up Your Node.js Environment
- Generating Secure OTP Codes
- Storing OTPs Safely with Redis
- Implementing SMS Delivery
- Building the Verification Endpoint
- Security Best Practices That Matter
- SMS vs Email: Making the Right Choice
- Common Implementation Mistakes
Understanding OTP Authentication Fundamentals
OTP authentication works on a simple principle: generate a random code, send it to the user through a channel they control (like their phone), and verify they can provide that code back to you. This proves they have access to that channel, adding a second factor beyond just knowing a password.
The typical flow looks like this:
- Request: A user requests an OTP during login or signup.
- Generation: Your server generates a random numeric code (usually 4-6 digits) and stores it temporarily with an expiration time.
- Delivery: The code is sent via SMS.
- Verification: The user submits the code back within the time window.
- Success: If the submitted code matches the stored one and hasn't expired, authentication succeeds.
The security comes from three factors working together:
- Unpredictability: The code is randomly generated.
- Time-Sensitivity: It expires quickly (typically 5-10 minutes).
- Possession: It targets a device the user physically possesses.
Setting Up Your Node.js Environment
Before diving into code, let's get your development environment ready. We'll use Express.js for the web framework, Redis for temporary code storage (essential for TTL management), and crypto-random-string for secure generation.
# Initialize your project and install dependencies
npm init -y
npm install express redis dotenv crypto-random-string axios
npm install --save-dev nodemon
Create a basic server structure that we'll build upon. This sets up Express with JSON parsing and a Redis connection.
// server.js
const express = require('express');
const redis = require('redis');
require('dotenv').config();
const app = express();
app.use(express.json());
// Redis client setup for storing OTPs
const redisClient = redis.createClient({
url: `redis://${process.env.REDIS_HOST || 'localhost'}:${process.env.REDIS_PORT || 6379}`
});
redisClient.on('error', (err) => {
console.error('Redis connection error:', err);
});
(async () => {
await redisClient.connect();
console.log('Connected to Redis');
})();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`OTP server running on port ${PORT}`);
});
Your .env file should contain configuration for Redis and your SMS service credentials:
REDIS_HOST=localhost
REDIS_PORT=6379
SMS_API_TOKEN=your_api_token_here
SMS_API_URL=https://papi.razaengage.com/SendSmsV2
Generating Secure OTP Codes
The heart of your OTP system is the code generation function. This needs to be cryptographically random, not just mathematically random. Using JavaScript's Math.random() is a security vulnerability because those numbers are predictable if an attacker knows the seed.
Here's a secure OTP generator that creates 6-digit codes using Node's crypto module:
// utils/otpGenerator.js
const cryptoRandomString = require('crypto-random-string');
function generateOTP(length = 6) {
// Generate a cryptographically secure random numeric string
return cryptoRandomString({ length, type: 'numeric' });
}
function generateOTPWithExpiry() {
const otp = generateOTP();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now
return {
code: otp,
expiresAt: expiresAt.toISOString()
};
}
module.exports = { generateOTP, generateOTPWithExpiry };
Storing OTPs Safely with Redis
You need temporary storage for OTPs that automatically handles expiration. Redis is perfect for this because it offers built-in TTL (time-to-live) functionality. When the TTL expires, Redis automatically deletes the key, ensuring old codes can't be reused.
// utils/otpStorage.js
async function storeOTP(phoneNumber, otpData, redisClient) {
const key = `otp:${phoneNumber}`;
const ttlSeconds = 600; // 10 minutes
// Store the OTP data as a JSON string
await redisClient.setEx(
key,
ttlSeconds,
JSON.stringify({
code: otpData.code,
attempts: 0, // Track verification attempts
createdAt: new Date().toISOString()
})
);
return true;
}
async function getOTP(phoneNumber, redisClient) {
const key = `otp:${phoneNumber}`;
const data = await redisClient.get(key);
if (!data) return null;
return JSON.parse(data);
}
async function incrementAttempts(phoneNumber, redisClient) {
const key = `otp:${phoneNumber}`;
const data = await getOTP(phoneNumber, redisClient);
if (!data) return null;
data.attempts += 1;
// Get remaining TTL to preserve it
const ttl = await redisClient.ttl(key);
await redisClient.setEx(key, ttl, JSON.stringify(data));
return data.attempts;
}
async function deleteOTP(phoneNumber, redisClient) {
const key = `otp:${phoneNumber}`;
await redisClient.del(key);
}
module.exports = { storeOTP, getOTP, incrementAttempts, deleteOTP };
Notice how we track verification attempts. This allows us to implement rate limiting later, preventing brute-force attacks where someone tries to guess the code.
Implementing SMS Delivery
Now comes the critical part: actually sending the OTP to users. SMS delivery needs to be reliable because if codes don't arrive, users can't authenticate.
Based on the RazaEngage API documentation, we will use the SendSmsV2 endpoint. This API supports both GET and POST requests, but we will use POST for better security and structure.
// services/smsService.js
const axios = require('axios');
async function sendOTP(phoneNumber, otpCode) {
const apiUrl = process.env.SMS_API_URL || 'https://papi.razaengage.com/SendSmsV2';
// Format phone number (Ensure country code is present)
const formattedNumber = phoneNumber.startsWith('91') ? phoneNumber : `91${phoneNumber}`;
const messageText = `Your OTP code is ${otpCode}. Valid for 10 minutes. Do not share this code.`;
try {
[cite_start]// Constructing the payload based on API docs [cite: 45]
const payload = {
[cite_start]apiToken: process.env.SMS_API_TOKEN, // [cite: 47]
[cite_start]messageType: "3", // "3" designates OTP traffic [cite: 66]
[cite_start]messageEncoding: "1", // "1" for ASCII [cite: 58]
[cite_start]destinationAddress: formattedNumber, // [cite: 50]
[cite_start]sourceAddress: "VERIFY", // Sender ID [cite: 51]
[cite_start]messageText: messageText, // [cite: 52]
[cite_start]// Optional: Callback URL for delivery reports [cite: 52]
callBackUrl: `${process.env.APP_URL}/api/sms-callback`,
[cite_start]userReferenceId: `otp_${Date.now()}_${phoneNumber}` // [cite: 54]
};
const response = await axios.post(apiUrl, payload);
[cite_start]// Check API response structure [cite: 67-76]
// The API returns an array or object containing OperationCode and Status
const result = response.data;
// Assuming response might be an array based on similar APIs, or direct object
const data = Array.isArray(result) ? result[0] : result;
[cite_start]if (data.OperationCode === 0 && data.Status === "Success") { // [cite: 70, 71]
return {
success: true,
[cite_start]messageId: data.MessageId, // [cite: 69]
[cite_start]remarks: data.Remarks // [cite: 75]
};
} else {
throw new Error(data.Remarks || 'SMS submission failed');
}
} catch (error) {
console.error('SMS sending error:', error.message);
throw new Error('Failed to send OTP via SMS');
}
}
module.exports = { sendOTP };
Ensuring Delivery Reliability
The callBackUrl parameter is valuable for production systems. It allows the SMS provider to push a delivery status (DLR) back to your server, confirming if the message was "Delivered," "Expired," or "Undeliverable" .
When implementing messaging for critical authentication flows, look for SMS delivery platforms with automatic retry mechanisms that can switch between carrier routes if the primary path fails. This ensures your OTPs arrive even during network congestion.
Building the Verification Endpoint
With code generation and SMS delivery in place, let's build the API endpoints that tie everything together.
// routes/otpRoutes.js
const express = require('express');
const router = express.Router();
const { generateOTPWithExpiry } = require('../utils/otpGenerator');
const { storeOTP, getOTP, incrementAttempts, deleteOTP } = require('../utils/otpStorage');
const { sendOTP } = require('../services/smsService');
// Simple in-memory rate limiter (Move to Redis for production)
const requestCounts = new Map();
function checkRateLimit(phoneNumber) {
const now = Date.now();
const windowMs = 60 * 60 * 1000; // 1 hour window
const maxRequests = 5;
const key = `rate:${phoneNumber}`;
const requests = requestCounts.get(key) || [];
const recentRequests = requests.filter(time => now - time < windowMs);
if (recentRequests.length >= maxRequests) return false;
recentRequests.push(now);
requestCounts.set(key, recentRequests);
return true;
}
// POST /api/otp/request
router.post('/request', async (req, res) => {
const { phoneNumber } = req.body;
if (!phoneNumber || !/^\d{10,12}$/.test(phoneNumber)) {
return res.status(400).json({ success: false, message: 'Valid phone number required' });
}
if (!checkRateLimit(phoneNumber)) {
return res.status(429).json({ success: false, message: 'Too many requests' });
}
try {
const otpData = generateOTPWithExpiry();
// Pass redisClient from app.locals or middleware
await storeOTP(phoneNumber, otpData, req.app.locals.redisClient);
await sendOTP(phoneNumber, otpData.code);
res.json({ success: true, message: 'OTP sent successfully', expiresAt: otpData.expiresAt });
} catch (error) {
console.error('OTP request error:', error);
res.status(500).json({ success: false, message: 'Failed to send OTP' });
}
});
// POST /api/otp/verify
router.post('/verify', async (req, res) => {
const { phoneNumber, code } = req.body;
try {
const storedData = await getOTP(phoneNumber, req.app.locals.redisClient);
if (!storedData) {
return res.status(400).json({ success: false, message: 'OTP expired or not found' });
}
if (storedData.attempts >= 3) {
await deleteOTP(phoneNumber, req.app.locals.redisClient);
return res.status(400).json({ success: false, message: 'Too many attempts. Request new OTP.' });
}
// Constant-time comparison should be used here for max security
if (storedData.code === code) {
await deleteOTP(phoneNumber, req.app.locals.redisClient);
// Generate JWT or Session here
return res.json({ success: true, message: 'Verified' });
} else {
await incrementAttempts(phoneNumber, req.app.locals.redisClient);
return res.status(400).json({ success: false, message: 'Invalid OTP' });
}
} catch (error) {
res.status(500).json({ success: false, message: 'Verification failed' });
}
});
module.exports = router;
Security Best Practices That Matter
Security in OTP systems goes beyond just generating random codes. Here are the practices that prevent real-world attacks:
-
Constant-Time Comparison: Use
crypto.timingSafeEqual()when verifying codes. This prevents timing attacks where an attacker guesses the code by measuring how long the server takes to reject incorrect digits. - Progressive Delays: Implement exponential backoff. After a failed attempt, force a 5-second wait. After a second failure, force 15 seconds. This kills brute-force efficiency.
- Burn After Reading: Never reuse OTP codes. Once a code is verified, delete it immediately.
- Logging & Monitoring: Track OTP request spikes. If a single IP requests OTPs for 100 different numbers, block it.
SMS vs Email: Making the Right Choice
The choice between SMS and email depends on your specific use case.
- SMS Advantages: High open rates (98%) and immediate delivery. It is ideal for transactional security where speed is critical. It also ties identity to a physical SIM card, which is harder to mass-compromise than email accounts.
- SMS Disadvantages: Cost per message (varies by country) and potential vulnerability to SIM swapping.
- Email Advantages: Free and global. Good for non-urgent verifications like initial account activation.
- Email Disadvantages: Slower delivery, spam folder issues, and higher risk of account compromise chains (if email is hacked, the user is vulnerable).
For authentication, SMS generally wins because user experience relies on immediacy. Users stuck waiting 2 minutes for an email code will abandon your app.
Common Implementation Mistakes
Even experienced developers make predictable mistakes:
-
Predictable RNG: Using
Math.random()or time-based seeds allows attackers to predict future codes. - Client-Side Validation Only: Never validate OTPs on the client. The verification logic must live on the server.
-
Logging OTPs: Leaving
console.log(otp)in your production code is a massive security hole. Use environment variables to ensure debug logs only run in development. - Ignoring Rate Limits: Without rate limits, your SMS budget will be drained by bot attacks in minutes.
Conclusion
Building secure OTP authentication requires attention to multiple layers: cryptographically random code generation, reliable SMS delivery infrastructure, and proper storage with automatic expiration. The implementation we've covered gives you production-ready code that handles these scenarios while avoiding common pitfalls.
As you build your system, remember to test with real phone numbers in your target markets, as SMS delivery behavior can vary significantly by region and carrier.
About the author: I write about technical infrastructure and SaaS growth strategies. At Raza Engage, we build the communication APIs that power secure, real-time user interactions for businesses globally.
Top comments (0)