DEV Community

Raza Engage
Raza Engage

Posted on

How to Implement OTP Authentication in Node.js: A Complete Guide for Secure User Verification

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

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:

  1. Request: A user requests an OTP during login or signup.
  2. Generation: Your server generates a random numeric code (usually 4-6 digits) and stores it temporarily with an expiration time.
  3. Delivery: The code is sent via SMS.
  4. Verification: The user submits the code back within the time window.
  5. 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

Enter fullscreen mode Exit fullscreen mode

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}`);
});

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Security Best Practices That Matter

Security in OTP systems goes beyond just generating random codes. Here are the practices that prevent real-world attacks:

  1. 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.
  2. 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.
  3. Burn After Reading: Never reuse OTP codes. Once a code is verified, delete it immediately.
  4. 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:

  1. Predictable RNG: Using Math.random() or time-based seeds allows attackers to predict future codes.
  2. Client-Side Validation Only: Never validate OTPs on the client. The verification logic must live on the server.
  3. 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.
  4. 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)