code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Node.js

How to Implement OTP Verification in Node.js: Complete SMS 2FA Tutorial with Express, Plivo & Redis

Learn how to build secure SMS OTP verification and two-factor authentication in Node.js using Express, Plivo SMS API, and Redis. Complete step-by-step tutorial with production-ready code examples, security best practices, rate limiting, and brute force protection for phone number verification.

Node.js OTP Verification: Build SMS 2FA with Express, Plivo & Redis

Two-factor authentication (2FA) adds a critical security layer to applications by requiring users to provide a second form of verification beyond just a password. One of the most common and user-friendly methods for implementing OTP verification is sending a one-time password (OTP) via SMS to verify phone numbers during user authentication.

This comprehensive guide shows you how to implement SMS OTP verification in Node.js using the Express framework, Redis for temporary OTP storage, and the Plivo SMS API for reliable message delivery. You'll learn everything from project setup and core logic to security best practices, error handling, rate limiting, and production deployment considerations for building a secure phone number verification system.

What You'll Build: Production-Ready OTP Authentication System

Project Goal: Build a secure backend API service that handles OTP generation, SMS delivery via Plivo, and verification for user phone numbers within a Node.js Express application.

Problem This Solves: This implementation addresses the need for enhanced application security by adding an SMS-based 2FA layer, protecting user accounts even if passwords are compromised. You'll have a reliable way to verify user phone numbers during registration, login, or critical actions like password resets and payment confirmations.

When to Use SMS OTP vs Other 2FA Methods

SMS-based OTP verification is widely used and provides meaningful security improvements over password-only authentication. However, NIST SP 800-63B guidelines (2024) discourage SMS as an out-of-band authenticator for high-security applications due to vulnerabilities in the SS7 telecom network and risks like SIM swapping.

For applications requiring higher security assurance levels (banking, healthcare, government), consider implementing stronger alternatives such as:

  • Time-based One-Time Passwords (TOTP) via authenticator apps (Google Authenticator, Authy)
  • FIDO2/WebAuthn hardware security keys
  • Push-based authentication

SMS OTP remains appropriate for moderate-security scenarios like e-commerce, social media, and general account verification where ease of use and universal accessibility are priorities. This guide demonstrates production-ready implementation patterns applicable to any OTP delivery method.

Technology Stack for SMS OTP Verification:

  • Node.js: JavaScript runtime environment for building scalable server-side applications.
  • Express.js: Minimal and flexible Node.js web application framework for building API endpoints.
  • Plivo: Cloud communications platform with reliable SMS API for sending OTP messages globally. Uses the official Plivo Node.js SDK.
  • Redis: In-memory data structure store for fast temporary storage with built-in expiration (TTL) on OTPs.
  • dotenv: Module that loads environment variables from a .env file into process.env, keeping sensitive credentials out of the codebase.
  • express-rate-limit: Middleware that limits repeated requests to public APIs, crucial for preventing abuse of OTP endpoints.
  • express-validator: Middleware for validating and sanitizing API request inputs (phone numbers, OTP codes).
  • crypto: Node.js built-in module for cryptographically strong random number generation.

System Architecture:

mermaid
graph LR
    A[User Client (Web/Mobile)] -- Request OTP --> B(Node.js/Express API);
    B -- Generate OTP & Store (Redis) --> C{Redis};
    B -- Send SMS via Plivo --> D[Plivo SMS API];
    D -- Delivers SMS --> E[User's Phone];
    A -- Submit OTP --> B;
    B -- Verify OTP (Redis) --> C;
    B -- Send Verification Result --> A;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style D fill:#9cf,stroke:#333,stroke-width:2px

Prerequisites for Building OTP Verification:

  • Node.js and npm (or yarn) installed. Node.js v22.x LTS recommended for production (Active LTS until October 2025, maintained until April 2027). Minimum Node.js v18.x required.
  • Access to a terminal or command prompt.
  • A Plivo account (Sign up for free). You will need your Auth ID, Auth Token, and an SMS-enabled Plivo phone number. Current Plivo Node.js SDK version: 4.74.0 (December 2024).
  • Redis v6.0+ installed and running locally or accessible via a cloud provider. (Installation guides: Official Redis Docs). Node Redis client v5.x is used in this guide.
  • Basic understanding of Node.js, Express, REST APIs, and asynchronous JavaScript.
  • A tool for making API requests (like curl, Postman, or Insomnia) for testing your OTP endpoints.

Final Outcome: A secure, robust, and testable Node.js Express API service with endpoints to request and verify SMS OTPs using Plivo for phone number authentication.

1. Set Up Your Node.js OTP Verification Project

Create the project directory, initialize Node.js, and install the necessary dependencies for building SMS OTP authentication.

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir node-plivo-otp
    cd node-plivo-otp
  2. Initialize Node.js Project: Create a package.json file to manage project dependencies and scripts.

    bash
    npm init -y
  3. Install Dependencies: Install Express, the Plivo SDK, the Redis client, dotenv for environment variables, and middleware for rate limiting and validation.

    bash
    npm install express plivo redis dotenv express-rate-limit express-validator
  4. Install Development Dependencies (Optional but Recommended): Install nodemon to automatically restart the server during development when file changes are detected.

    bash
    npm install --save-dev nodemon
  5. Create Project Structure: Set up a basic structure for clarity.

    bash
    mkdir src
    touch src/app.js src/config.js src/otp.service.js src/routes.js .env .gitignore
    • src/app.js: Main application file (Express server setup).
    • src/config.js: Application configuration (loaded from environment variables).
    • src/otp.service.js: Business logic for OTP generation, storage, and Plivo interaction.
    • src/routes.js: Defines the API endpoints.
    • .env: Stores sensitive credentials (Plivo Auth ID, Token, Number, Redis URL). Never commit this file.
    • .gitignore: Specifies intentionally untracked files that Git should ignore (like node_modules and .env).
  6. Configure .gitignore: Add the following lines to your .gitignore file:

    text
    # Dependencies
    node_modules/
    
    # Environment variables
    .env
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # Optional editor directories
    .idea
    .vscode
    *.suo
    *.ntvs*
    *.njsproj
    *.sln
    *.sw?
  7. Configure Environment Variables (.env): Open the .env file and add your Plivo credentials, Plivo phone number, and Redis connection details. Replace the placeholders with your actual values. For production Redis instances requiring authentication, use the format redis://:yourpassword@your_redis_host:6379.

    dotenv
    # .env
    
    # Plivo Credentials (Find these on your Plivo Console Dashboard: https://console.plivo.com/dashboard/)
    PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
    PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
    PLIVO_PHONE_NUMBER=YOUR_PLIVO_SMS_ENABLED_NUMBER_IN_E164_FORMAT # e.g., +14151112222
    
    # Redis Configuration
    # For local unsecured Redis:
    REDIS_URL=redis://127.0.0.1:6379
    # For production Redis with password:
    # REDIS_URL=redis://:YOUR_REDIS_PASSWORD@your_redis_host.com:6379
    
    # Application Port
    PORT=3000
    
    # OTP Settings
    OTP_LENGTH=6
    OTP_EXPIRY_SECONDS=300 # 5 minutes
    • PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN: Your API credentials from the Plivo dashboard. Essential for authenticating API requests.
    • PLIVO_PHONE_NUMBER: The SMS-enabled number purchased from Plivo, used as the sender ID for OTP messages. Must be in E.164 format (e.g., +12125551234).
    • REDIS_URL: Connection string for your Redis instance. Used by the Redis client to connect. Ensure it includes credentials if needed for production.
    • PORT: The port your Express application will listen on.
    • OTP_LENGTH: The desired number of digits for the OTP.
    • OTP_EXPIRY_SECONDS: How long (in seconds) the OTP remains valid in Redis.
  8. Load Environment Variables (src/config.js): Configure the application to load these variables using dotenv.

    javascript
    // src/config.js
    require('dotenv').config(); // Load .env file variables into process.env
    
    const config = {
        plivo: {
            authId: process.env.PLIVO_AUTH_ID,
            authToken: process.env.PLIVO_AUTH_TOKEN,
            phoneNumber: process.env.PLIVO_PHONE_NUMBER,
        },
        redis: {
            url: process.env.REDIS_URL,
        },
        app: {
            port: parseInt(process.env.PORT, 10) || 3000,
        },
        otp: {
            length: parseInt(process.env.OTP_LENGTH, 10) || 6,
            expiry: parseInt(process.env.OTP_EXPIRY_SECONDS, 10) || 300, // Default 5 minutes
        },
    };
    
    // Basic validation to ensure essential variables are set
    if (!config.plivo.authId || !config.plivo.authToken || !config.plivo.phoneNumber) {
        console.error('FATAL ERROR: Plivo credentials are not set in the environment variables.');
        process.exit(1); // Exit if essential config is missing
    }
    
    if (!config.redis.url) {
        console.error('FATAL ERROR: Redis URL is not set in the environment variables.');
        process.exit(1);
    }
    
    module.exports = config;
    • Why dotenv? It keeps sensitive information like API keys out of your source code, which is crucial for security, especially when using version control like Git.
    • Why validation? Checking for essential config variables at startup prevents runtime errors later and provides clear feedback if the setup is incomplete.
  9. Add Run Scripts to package.json: Modify the scripts section in your package.json. Note that the default test script is a placeholder; for production, you should implement actual unit and integration tests.

    json
    // package.json (scripts section)
    "scripts": {
      "start": "node src/app.js",
      "dev": "nodemon src/app.js",
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    • npm start: Runs the application using Node.
    • npm run dev: Runs the application using nodemon, which watches for file changes and restarts the server automatically – ideal for development.
    • npm test: Placeholder script. Real tests using frameworks like Jest or Mocha are recommended.

Now the basic project structure, dependencies, and configuration loading are in place.

2. Implement OTP Generation and Storage with Redis

This section focuses on the heart of the OTP system: generating, storing, sending, and verifying OTPs. You'll implement this logic within src/otp.service.js.

  1. Initialize Plivo and Redis Clients: We need instances of the Plivo client to send SMS and the Redis client to store/retrieve OTPs.

    javascript
    // src/otp.service.js
    const plivo = require('plivo');
    const redis = require('redis');
    const crypto = require('crypto'); // Use crypto for better random number generation
    const config = require('./config'); // Load configuration
    
    // --- Client Initialization ---
    
    // Initialize Plivo Client
    const plivoClient = new plivo.Client(config.plivo.authId, config.plivo.authToken);
    
    // Initialize Redis Client
    const redisClient = redis.createClient({
        url: config.redis.url,
    });
    
    redisClient.on('error', (err) => console.error('Redis Client Error:', err));
    redisClient.on('connect', () => console.log('Connected to Redis successfully.'));
    
    // Connect to Redis (important!)
    // Added catch here in case initial connection fails during startup
    redisClient.connect().catch(err => {
        console.error('Failed to connect to Redis on startup:', err);
        // Depending on the app's requirements, you might want to exit here
        // process.exit(1);
    });
    
    // --- OTP Generation ---
    
    /**
     * Generates a cryptographically strong random numeric OTP.
     * @param {number} length - The desired length of the OTP.
     * @returns {string} The generated OTP.
     */
    function generateOtp(length = config.otp.length) {
        const otpLength = Math.max(1, Math.floor(length));
        try {
            // Calculate the min and max values for the desired length
            const min = Math.pow(10, otpLength - 1);
            const max = Math.pow(10, otpLength); // Max is exclusive for crypto.randomInt
            // Generate a random integer within the range [min, max) and convert to string
            return crypto.randomInt(min, max).toString();
        } catch (error) {
            console.error('Error generating OTP using crypto.randomInt:', error);
            // Fallback or throw error - simple fallback for demo purposes
            let fallbackOtp = '';
            for (let i = 0; i < otpLength; i++) {
                fallbackOtp += Math.floor(Math.random() * 10);
            }
            console.warn('Using Math.random as fallback for OTP generation.');
            return fallbackOtp;
        }
    }
    
    // --- OTP Storage (Redis) ---
    
    /**
     * Stores the OTP in Redis with an expiry time.
     * @param {string} phoneNumber - The phone number (used as part of the key).
     * @param {string} otp - The OTP to store.
     * @returns {Promise<void>}
     */
    async function storeOtp(phoneNumber, otp) {
        const key = `otp:${phoneNumber}`;
        try {
            // Check if client is ready before attempting to set
            if (!redisClient.isReady) {
                 console.error('Redis client is not ready. Cannot store OTP.');
                 throw new Error('Failed to store OTP - Redis connection issue.');
            }
            await redisClient.set(key, otp, {
                EX: config.otp.expiry, // Set expiry time in seconds
                NX: false, // Allow overwriting existing key if needed
            });
            console.log(`OTP stored for ${phoneNumber}, expires in ${config.otp.expiry} seconds.`);
        } catch (error) {
            console.error(`Error storing OTP for ${phoneNumber}:`, error);
            // Avoid re-throwing the exact same error message if it's already specific
            if (error.message.includes('Redis connection issue')) {
                throw error;
            }
            throw new Error('Failed to store OTP'); // Re-throw for API layer handling
        }
    }
    
    // --- OTP Sending (Plivo SMS) ---
    
    /**
     * Sends the OTP via SMS using Plivo.
     * @param {string} recipientNumber - The E.164 formatted phone number to send the SMS to.
     * @param {string} otp - The OTP code to send.
     * @returns {Promise<object>} Plivo API response.
     */
    async function sendOtpSms(recipientNumber, otp) {
        const message = `Your verification code is: ${otp}`;
        console.log(`Attempting to send OTP to ${recipientNumber}`);
    
        try {
            const response = await plivoClient.messages.create(
                config.plivo.phoneNumber, // Sender number (from config)
                recipientNumber,          // Recipient number
                message                   // Message text
            );
            console.log(`SMS sent successfully to ${recipientNumber}. Message UUID:`, response.messageUuid);
            return response;
        } catch (error) {
            console.error(`Error sending SMS via Plivo to ${recipientNumber}:`, error);
            // Provide more specific error feedback if possible
            let errorMessage = 'Failed to send OTP SMS.';
            if (error.message) {
                 errorMessage += ` Plivo error: ${error.message}`;
            }
            throw new Error(errorMessage); // Re-throw for API layer handling
        }
    }
    
    // --- OTP Verification ---
    
    /**
     * Verifies the submitted OTP against the one stored in Redis.
     * @param {string} phoneNumber - The phone number associated with the OTP.
     * @param {string} submittedOtp - The OTP submitted by the user.
     * @returns {Promise<boolean>} True if OTP is valid, false otherwise.
     */
    async function verifyOtp(phoneNumber, submittedOtp) {
        const key = `otp:${phoneNumber}`;
        try {
            // Check if client is ready
            if (!redisClient.isReady) {
                console.error('Redis client is not ready. Cannot verify OTP.');
                throw new Error('Failed to verify OTP - Redis connection issue.');
            }
            const storedOtp = await redisClient.get(key);
    
            if (!storedOtp) {
                console.log(`No OTP found for ${phoneNumber} (likely expired or never existed).`);
                return false; // OTP expired or never existed
            }
    
            if (storedOtp === submittedOtp) {
                console.log(`OTP verification successful for ${phoneNumber}.`);
                // Optional: Delete the OTP immediately after successful verification
                // to prevent reuse, though expiry handles this eventually.
                // Use await with del, and handle potential errors
                try {
                    await redisClient.del(key);
                } catch (delError) {
                    console.error(`Error deleting OTP key ${key} after successful verification:`, delError);
                    // Continue, as verification succeeded, but log the cleanup failure.
                }
                return true;
            } else {
                console.log(`OTP verification failed for ${phoneNumber}. Submitted: ${submittedOtp}`); // Avoid logging expected OTP
                // Optional: Implement attempt tracking here if needed
                return false; // Incorrect OTP
            }
        } catch (error) {
            console.error(`Error verifying OTP for ${phoneNumber}:`, error);
             // Avoid re-throwing the exact same error message if it's already specific
            if (error.message.includes('Redis connection issue')) {
                throw error;
            }
            throw new Error('Failed to verify OTP due to a system error.'); // Re-throw
        }
    }
    
    
    // --- Export Functions ---
    module.exports = {
        generateOtp,
        storeOtp,
        sendOtpSms,
        verifyOtp,
        redisClient, // Export client for graceful shutdown in app.js
        // plivoClient, // Export if needed elsewhere
    };
    • Why Redis? Redis is ideal for OTPs because it's fast (in-memory) and has built-in support for key expiration (EX), automatically cleaning up old OTPs.
    • Why async/await? Plivo and Redis operations are asynchronous (network I/O). async/await provides a cleaner way to handle promises compared to .then() chains.
    • Error Handling: try...catch blocks are used. Errors are logged, and custom error messages are thrown to be handled by the API layer. Redis connection readiness is checked.
    • OTP Generation: Uses Node's built-in crypto.randomInt for generating a cryptographically stronger pseudo-random numeric OTP, which is preferred for production over Math.random.
    • Plivo SMS: The sendOtpSms function uses plivoClient.messages.create with the sender number from config, the recipient number, and the message text including the OTP.
    • Verification Logic: verifyOtp fetches the OTP from Redis using the phone number key. It checks if the OTP exists (not expired) and if it matches the submitted code. It deletes the key on successful verification (best practice).

3. Build Express API Endpoints for OTP Request and Verification

Now, let's create the Express routes that expose our OTP functionality as API endpoints for phone number verification.

  1. Define Routes (src/routes.js): We need two main endpoints: one to request an OTP and one to verify it. We'll also use express-validator for input validation.

    javascript
    // src/routes.js
    const express = require('express');
    const { body, validationResult } = require('express-validator');
    const otpService = require('./otp.service');
    const config = require('./config');
    
    const router = express.Router();
    
    // --- Input Validation Middleware ---
    const validatePhoneNumber = [
        body('phoneNumber')
            .trim()
            .isMobilePhone('any', { strictMode: true }) // Validates E.164 format implicitly for many locales
            .withMessage('Valid E.164 formatted phone number is required (e.g., +14155552671).')
            // Add custom validation/sanitization if needed, e.g., remove non-digit chars except '+'
            .customSanitizer(value => value.replace(/[^+\d]/g, '')),
    ];
    
    const validateVerificationPayload = [
        ...validatePhoneNumber, // Reuse phone number validation
        body('otp')
            .trim()
            .isLength({ min: config.otp.length, max: config.otp.length })
            .withMessage(`OTP must be exactly ${config.otp.length} digits.`)
            .isNumeric()
            .withMessage('OTP must contain only numeric digits.'),
    ];
    
    // Middleware to handle validation results
    const handleValidationErrors = (req, res, next) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            // Log the validation errors for debugging
            console.warn('Validation Errors:', errors.array());
            // Return a 400 Bad Request with the first error message
            return res.status(400).json({ message: errors.array()[0].msg });
        }
        next(); // Proceed to the route handler if validation passes
    };
    
    
    // --- API Endpoints ---
    
    /**
     * @route   POST /api/otp/request
     * @desc    Request an OTP to be sent to a phone number
     * @access  Public
     * @body    { "phoneNumber": "+14155552671" }
     */
    router.post(
        '/request',
        validatePhoneNumber,      // Apply phone number validation
        handleValidationErrors,   // Handle any validation errors
        async (req, res) => {
            const { phoneNumber } = req.body; // Already validated and sanitized
    
            try {
                // 1. Generate OTP
                const otp = otpService.generateOtp();
    
                // 2. Store OTP (associating it with the phone number)
                await otpService.storeOtp(phoneNumber, otp);
    
                // 3. Send OTP via SMS
                await otpService.sendOtpSms(phoneNumber, otp);
    
                // IMPORTANT: Do NOT log the actual OTP in production environments.
                console.log(`OTP request successful for ${phoneNumber}. (OTP sent via Plivo)`);
                res.status(200).json({ message: 'OTP has been sent successfully.' });
    
            } catch (error) {
                console.error(`Error processing OTP request for ${phoneNumber}:`, error);
                // Check for specific errors thrown by the service
                if (error.message.includes('Plivo error')) {
                     res.status(502).json({ message: 'Failed to send OTP due to provider issue.' }); // Bad Gateway
                } else if (error.message.includes('Failed to store OTP')) {
                     res.status(500).json({ message: 'Failed to process OTP request due to storage issue.' });
                } else {
                     res.status(500).json({ message: 'An internal server error occurred.' });
                }
            }
        }
    );
    
    /**
     * @route   POST /api/otp/verify
     * @desc    Verify an OTP submitted for a phone number
     * @access  Public
     * @body    { "phoneNumber": "+14155552671", "otp": "123456" }
     */
    router.post(
        '/verify',
        validateVerificationPayload, // Apply phone number and OTP validation
        handleValidationErrors,      // Handle any validation errors
        async (req, res) => {
            const { phoneNumber, otp } = req.body; // Validated and sanitized
    
            try {
                // 1. Verify OTP against stored value
                const isValid = await otpService.verifyOtp(phoneNumber, otp);
    
                if (isValid) {
                    res.status(200).json({ message: 'OTP verified successfully.' });
                } else {
                    // Use 400 for invalid/expired OTP - it's a client-side issue (wrong input or delay)
                    res.status(400).json({ message: 'Invalid or expired OTP.' });
                }
            } catch (error) {
                console.error(`Error processing OTP verification for ${phoneNumber}:`, error);
                 res.status(500).json({ message: 'An internal server error occurred during verification.' });
            }
        }
    );
    
    module.exports = router;
    • Input Validation: express-validator checks if phoneNumber looks like a valid mobile number (using isMobilePhone) and if the otp has the correct length and is numeric. This prevents invalid data from reaching the service layer. The handleValidationErrors middleware centralizes error reporting for validation failures.
    • Route Logic: Each route handler calls the corresponding functions from otp.service.js. It wraps calls in try...catch to handle errors gracefully and return appropriate HTTP status codes (e.g., 200 for success, 400 for bad input/invalid OTP, 500/502 for server/provider errors).
    • Sanitization: Using .trim() and .customSanitizer helps clean up input before validation and processing.
    • Security: The line logging the actual OTP code in the /request endpoint has been removed.
  2. Integrate Routes into Express App (src/app.js): Set up the Express application, include necessary middleware (JSON body parsing, rate limiting), and mount the OTP routes.

    javascript
    // src/app.js
    const express = require('express');
    const rateLimit = require('express-rate-limit');
    const config = require('./config');
    const otpRoutes = require('./routes');
    const { redisClient } = require('./otp.service'); // Import redisClient for graceful shutdown
    
    const app = express();
    
    // --- Middleware ---
    
    // 1. Body Parser: Enable Express to parse JSON request bodies
    app.use(express.json());
    
    // 2. Rate Limiting: Apply basic rate limiting to all API requests
    //    Adjust limits based on expected traffic and security needs.
    const limiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100, // Limit each IP to 100 requests per windowMs
        message: 'Too many requests from this IP, please try again after 15 minutes',
        standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
        legacyHeaders: false, // Disable the `X-RateLimit-*` headers
    });
    app.use('/api', limiter); // Apply limiter only to API routes
    
    // Apply stricter rate limiting specifically to OTP routes
    const otpLimiter = rateLimit({
        windowMs: 5 * 60 * 1000, // 5 minutes
        max: 10, // Limit each IP to 10 OTP requests/verifications per 5 minutes
        message: 'Too many OTP requests or verification attempts, please try again later.',
        standardHeaders: true,
        legacyHeaders: false,
    });
     app.use('/api/otp', otpLimiter);
    
    
    // --- Routes ---
    app.get('/', (req, res) => {
        res.send(`OTP Service is running. Current date: ${new Date().toISOString()}`); // Simple health check / info endpoint
    });
    
    // Mount the OTP API routes
    app.use('/api/otp', otpRoutes);
    
    
    // --- Basic Error Handling Middleware (Optional but Recommended) ---
    // Catches errors thrown from async route handlers if not caught locally
    app.use((err, req, res, next) => {
        console.error('Unhandled Error:', err.stack || err);
        // Avoid sending stack traces in production
        res.status(500).json({ message: 'Something went wrong on the server.' });
    });
    
    
    // --- Start Server ---
    const PORT = config.app.port;
    // Assign the return value of app.listen to the server variable
    const server = app.listen(PORT, () => {
        console.log(`Server running on port ${PORT}`);
        console.log(`API endpoints available at http://localhost:${PORT}/api/otp`);
        console.log(`Using Plivo Number: ${config.plivo.phoneNumber}`);
        // Verify Redis connection is established before accepting requests fully
        // (Redis client connection is initiated in otp.service.js)
        if (redisClient.isReady) {
            console.log('Redis client is ready.');
        } else {
            console.warn('Redis client is not yet connected. Waiting for connection...');
        }
    });
    
    // Graceful shutdown
    process.on('SIGTERM', () => {
        console.log('SIGTERM signal received: closing HTTP server');
        server.close(() => {
            console.log('HTTP server closed');
            // Close Redis connection if it's open and the client exists
            if (redisClient && redisClient.isReady) {
                redisClient.quit()
                    .then(() => console.log('Redis connection closed successfully.'))
                    .catch((err) => console.error('Error closing Redis connection:', err));
            } else {
                 console.log('Redis client not connected or unavailable, skipping quit.');
            }
        });
    });
    • express.json(): Middleware to parse incoming JSON request bodies, making req.body available.
    • Rate Limiting: express-rate-limit is crucial to prevent brute-force attacks or abuse of the OTP system.
    • Route Mounting: The OTP routes are mounted under /api/otp.
    • Basic Error Handler: A final middleware catches unhandled errors.
    • Graceful Shutdown: The app.listen return value is assigned to server. The redisClient is imported from otp.service.js and redisClient.quit() is called within the shutdown handler, ensuring resources are released properly. Checks are added to ensure the client exists and is ready before attempting to quit.

4. Configure Plivo SMS API for Phone Number Verification

You've already initialized the Plivo client using credentials from .env. Here's a recap of the crucial configuration steps for integrating Plivo SMS API:

  1. Sign Up for Plivo: Create an account at plivo.com.
  2. Get Credentials: Navigate to the Plivo Console dashboard. Your Auth ID and Auth Token are displayed prominently.
  3. Buy an SMS-Enabled Number: Go to "Phone Numbers" -> "Buy Numbers" in the console. Search for a number with SMS capability. Ensure it supports sending messages to your target regions.
  4. Add Credits: Ensure you have sufficient credits in your Plivo account.
  5. Securely Store Credentials: Place your Auth ID, Auth Token, and Plivo phone number in the .env file. Never commit .env to version control.

Fallback Mechanisms: While this guide focuses on SMS OTP, Plivo supports Voice calls for OTP delivery. Implementing a fallback (e.g., if SMS fails or user requests it) would typically involve:

  • Using plivoClient.calls.create to initiate a text-to-speech call reading the OTP.
  • Setting up a Plivo Application in the Plivo console.
  • Configuring an "Answer URL" for that application, pointing to another endpoint in your Node.js app that returns Plivo XML (specifically the <Speak> element) to read the code. Alternatively, Plivo's visual workflow builder, PHLO, can orchestrate this.
  • Adding logic in your service/routes to trigger the voice call under specific conditions.

This setup is beyond the scope of this API guide but is documented on Plivo's developer portal.

5. Implement Error Handling, Logging, and Retry Logic

Robust error handling and logging are essential for production OTP systems.

  • Consistent Error Strategy:

    • Service Layer (otp.service.js): Catches specific errors, logs them, and throws standardized errors.
    • API Layer (routes.js): Catches service errors, logs them, sends appropriate HTTP status codes (4xx/5xx) and user-friendly JSON messages.
    • App Layer (app.js): Includes a final middleware for unhandled exceptions.
  • Logging:

    • Current Implementation: Uses console.log/warn/error.
    • Production Logging: Use a library like winston or pino for structured logging (JSON), log levels, and multiple transports (file, external services).
    • What to Log: Successful requests/verifications (INFO, never log the OTP code), validation failures (WARN), errors during storage/sending (ERROR, include context like phone prefix/suffix carefully), Plivo/Redis errors (ERROR), rate limit hits (INFO/WARN).
  • Retry Mechanisms:

    • Plivo SMS: Plivo handles some retries internally. For transient network errors between your server and Plivo, implement retries in otp.service.js.

    • Simple Retry Example:

      javascript
      async function sendOtpSmsWithRetry(recipientNumber, otp, retries = 2) {
          try {
              // It's better to call the core function that interacts with Plivo directly
              // Assuming sendOtpSms is the function making the actual Plivo call
              return await sendOtpSms(recipientNumber, otp);
          } catch (error) {
              console.error(`Attempt failed: ${error.message}. Retries left: ${retries}`);
              if (retries > 0) {
                  // Optional: Add a delay before retrying (e.g., exponential backoff)
                  await new Promise(resolve => setTimeout(resolve, 1000)); // 1-second delay
                  return sendOtpSmsWithRetry(recipientNumber, otp, retries - 1);
              } else {
                  console.error('Max retries reached. Failed to send OTP via Plivo.');
                  throw error; // Re-throw the final error
              }
          }
      }

      Note: This is a basic example. Production retries often involve exponential backoff and jitter.

    • Redis: Redis operations are generally fast and reliable. Implement connection retry logic if needed, but monitor Redis health closely.

6. Security Best Practices for OTP Verification

Rate Limiting and Brute Force Prevention

The current implementation uses IP-based rate limiting via express-rate-limit, which is essential but insufficient on its own. Production OTP systems should implement multiple layers of protection:

Critical Security Measures:

  1. Per-User Rate Limiting: Track OTP verification attempts per phone number, not just per IP address. Attackers can bypass IP-based limits using proxies or distributed attacks.
javascript
// Example: Track failed verification attempts in Redis
async function trackVerificationAttempt(phoneNumber) {
    const attemptsKey = `otp_attempts:${phoneNumber}`;
    const attempts = await redisClient.incr(attemptsKey);

    // Set expiry on first attempt (e.g., 30 minutes)
    if (attempts === 1) {
        await redisClient.expire(attemptsKey, 1800);
    }

    // Lock account after 5 failed attempts
    if (attempts > 5) {
        const lockKey = `otp_locked:${phoneNumber}`;
        await redisClient.set(lockKey, '1', { EX: 3600 }); // 1-hour lockout
        throw new Error('Too many failed attempts. Account temporarily locked.');
    }

    return attempts;
}
  1. Account Lockout: Implement temporary account lockout after excessive failed verification attempts (e.g., 5–10 attempts within 30 minutes). This is more effective than rate limiting alone.

  2. CAPTCHA After Failed Attempts: Add CAPTCHA verification after 2–3 failed OTP verification attempts to prevent automated brute force attacks.

  3. OTP Attempt Limit: Invalidate the OTP after a fixed number of verification attempts (e.g., 3–5 attempts) regardless of expiry time. Update verifyOtp function:

javascript
async function verifyOtp(phoneNumber, submittedOtp) {
    const key = `otp:${phoneNumber}`;
    const attemptsKey = `otp_verify_attempts:${phoneNumber}`;

    try {
        // Check if locked
        const isLocked = await redisClient.get(`otp_locked:${phoneNumber}`);
        if (isLocked) {
            throw new Error('Account temporarily locked due to too many failed attempts.');
        }

        const storedOtp = await redisClient.get(key);
        if (!storedOtp) {
            return false;
        }

        // Track verification attempts for this OTP
        const attempts = await redisClient.incr(attemptsKey);
        if (attempts === 1) {
            await redisClient.expire(attemptsKey, config.otp.expiry);
        }

        // Invalidate OTP after max attempts
        if (attempts > 5) {
            await redisClient.del(key);
            await redisClient.del(attemptsKey);
            console.log(`OTP invalidated for ${phoneNumber} after ${attempts} attempts`);
            return false;
        }

        if (storedOtp === submittedOtp) {
            // Clear attempts on success
            await redisClient.del(key);
            await redisClient.del(attemptsKey);
            return true;
        }

        return false;
    } catch (error) {
        console.error(`Error verifying OTP for ${phoneNumber}:`, error);
        throw error;
    }
}
  1. Shorter OTP Expiry: Consider reducing OTP validity from 5 minutes to 2–3 minutes (120–180 seconds) to reduce the brute force window. Update .env: OTP_EXPIRY_SECONDS=120

  2. Request Throttling: Limit OTP generation requests per phone number (e.g., maximum 3 OTP requests per hour per phone number) to prevent SMS flooding attacks and cost abuse.

Additional Security Recommendations

  • Constant-Time Comparison: While string comparison for numeric OTPs is generally safe, for extra security, use constant-time comparison to prevent timing attacks:

    javascript
    const crypto = require('crypto');
    function constantTimeCompare(a, b) {
        if (a.length !== b.length) return false;
        return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
    }
  • OTP Length: Use 6-digit OTPs minimum. Longer OTPs (8 digits) provide stronger security but may impact user experience.

  • Secure Redis Connection: Always use TLS/SSL for Redis connections in production (rediss:// protocol). Never expose Redis to the public internet without authentication.

  • Monitor Anomalies: Log and alert on suspicious patterns like: repeated OTP requests from the same IP, high verification failure rates, or requests for non-existent phone numbers.

  • Phone Number Verification: Validate phone numbers are properly formatted (E.164) and optionally verify they exist using phone validation APIs before sending OTPs to prevent SMS cost abuse.

Redis Client v5 Specific Considerations

Critical: Node Redis v5 requires explicit error handling. Always attach error listeners to prevent process crashes:

javascript
redisClient.on('error', (err) => {
    console.error('Redis Client Error:', err);
    // Implement alerting/monitoring here
});

Connection Management: Unlike v3, Redis client v5 requires calling .connect() explicitly. The connection is not automatic. Ensure proper connection lifecycle management in production.

Frequently Asked Questions About OTP Verification in Node.js

How do I implement OTP verification in Node.js?

Implement OTP verification in Node.js by: (1) generating a random numeric code using crypto.randomInt, (2) storing it temporarily in Redis with expiration, (3) sending it via SMS using a provider like Plivo, and (4) verifying the user-submitted code against the stored value. Use Express.js to create API endpoints for requesting and verifying OTPs.

The recommended OTP expiry time is 2–3 minutes (120–180 seconds) for security. While 5 minutes is common, shorter expiry windows reduce the brute force attack surface. Balance security with user experience – users need enough time to receive and enter the code.

How secure is SMS-based two-factor authentication?

SMS-based 2FA is moderately secure and significantly better than password-only authentication. However, NIST SP 800-63B (2024) discourages SMS for high-security applications due to SS7 network vulnerabilities and SIM swapping risks. For banking, healthcare, or government applications, use TOTP authenticator apps or FIDO2 hardware keys instead.

How do I prevent OTP brute force attacks?

Prevent OTP brute force attacks by implementing: (1) per-user rate limiting (not just IP-based), (2) account lockout after 5–10 failed attempts, (3) OTP invalidation after 3–5 verification attempts, (4) CAPTCHA after failed attempts, (5) shorter expiry times, and (6) request throttling per phone number. Use Redis to track attempts across sessions.

What is the best length for OTP codes?

The best length for OTP codes is 6 digits minimum. Six-digit OTPs provide 1 million possible combinations and balance security with user convenience. Eight-digit OTPs (100 million combinations) offer stronger security but may frustrate users. Avoid 4-digit OTPs – they're too easily brute-forced (10,000 combinations).

Why use Redis for OTP storage instead of a database?

Use Redis for OTP storage because: (1) in-memory operations are extremely fast (microseconds vs milliseconds), (2) built-in TTL (time-to-live) automatically expires old OTPs without cleanup jobs, (3) atomic operations prevent race conditions, and (4) it's designed for temporary data. Traditional databases add unnecessary overhead for short-lived data.

How do I integrate Plivo SMS API with Node.js?

Integrate Plivo SMS API by: (1) installing the official plivo npm package, (2) initializing the client with your Auth ID and Auth Token, (3) calling plivoClient.messages.create() with sender number (E.164 format), recipient, and message text. Store credentials in environment variables using dotenv. Plivo SDK version 4.74.0 (December 2024) works with Node.js v18+.

What Node.js version should I use for production OTP systems?

Use Node.js v22.x LTS for production OTP systems (Active LTS until October 2025, maintained until April 2027). Minimum requirement is Node.js v18.x. LTS (Long Term Support) versions receive security updates and are stable for production. Avoid odd-numbered versions (v19, v21, v23) – they're for testing only.

How do I handle SMS delivery failures?

Handle SMS delivery failures by: (1) implementing retry logic with exponential backoff (2–3 attempts), (2) logging failures with phone number prefixes for debugging, (3) providing fallback options like voice calls, (4) validating phone numbers before sending, and (5) monitoring Plivo's delivery receipts (DLRs). Return user-friendly error messages without exposing system details.

What's the difference between OTP and TOTP?

OTP (One-Time Password) is a single-use code sent via SMS or email that's valid for a fixed time. TOTP (Time-based One-Time Password) is generated by an algorithm using a shared secret and current time, displayed in authenticator apps like Google Authenticator. TOTP is more secure (no SMS interception risk) but requires users to install an app. Use TOTP for high-security applications.