code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Node.js Fastify OTP/2FA with Plivo SMS: Complete Implementation Guide 2025

Build production-ready SMS OTP authentication with Node.js, Fastify, Plivo, and Redis. Includes security best practices, OWASP guidelines, code examples, and deployment.

Implementing Node.js Fastify OTP/2FA with Plivo

Two-factor authentication (2FA) adds security to applications by requiring users to provide a second verification form beyond a password. This guide uses one-time passwords (OTPs) sent via SMS.

Build a production-ready OTP verification system using Node.js with Fastify and Plivo's SMS API. This guide covers project setup, deployment, testing, security, error handling, and reliability best practices.

⚠️ Security Advisory: SMS OTP Vulnerabilities

Important: While SMS-based OTP is widely deployed and functional, OWASP security guidelines (2025) and NIST (National Institute of Standards and Technology) identify SMS OTP as less secure than alternative 2FA methods due to known vulnerabilities:

  • SIM Swapping: Attackers convince mobile carriers to transfer a target's phone number to an attacker-controlled SIM card, intercepting all SMS messages. The FBI reported over $72 million stolen via SIM swap schemes in 2022.
  • SS7 Protocol Exploits: The Signaling System 7 (SS7) protocol used by mobile networks has security flaws allowing attackers to redirect SMS messages to their own devices.
  • Phishing & Smishing: Fraudsters send fake SMS messages impersonating legitimate services to trick users into revealing OTPs on malicious websites.
  • Man-in-the-Middle Attacks: Attackers can intercept SMS messages through cell tower emulation or malware on mobile devices.

NIST Guidance: NIST's digital identity guidelines have discouraged SMS-based authentication since 2016, considering it insecure and easily exploitable for targeted attacks.

Recommended Alternatives: For high-security applications, consider implementing:

  • FIDO2/Passkeys: Phishing-resistant authentication using public-key cryptography and biometrics
  • TOTP Authenticator Apps: Google Authenticator, Authy, or similar apps that generate codes locally without network transmission

When to Use SMS OTP: SMS OTP remains acceptable for low-to-medium risk applications where convenience is prioritized, or as a fallback method alongside more secure primary 2FA options. Any MFA is better than no MFA.

Citation1. From source: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html, Title: OWASP Multifactor Authentication Cheat Sheet, Text: While the disadvantages and weaknesses of various different types of MFA are only relevant against targeted attacks, any MFA is better than no MFA. NIST's digital identity guidelines have discouraged SMS-based authentication since 2016.

Project Overview and Goals

Goal: Build a secure and reliable SMS-based OTP verification API using Node.js, Fastify, Redis, and Plivo.

Problem Solved: Protects user accounts from unauthorized access through 2FA, even if passwords are compromised.

Technologies:

  • Node.js: JavaScript runtime for server-side applications.
  • Fastify: High-performance web framework for Node.js. Chosen for speed, extensibility, and built-in schema validation.
  • Plivo: Cloud communications platform providing SMS API services. Chosen for reliable message delivery and straightforward API.
  • Redis: In-memory data store for temporarily storing OTPs with automatic expiration. Chosen for speed and suitability for caching.
  • otp-generator: Library for generating cryptographically secure OTPs.
  • dotenv / @fastify/env: For managing environment variables securely.

Architecture:

mermaid
sequenceDiagram
    participant User
    participant Fastify API
    participant Redis
    participant Plivo API

    User->>+Fastify API: POST /request-otp (phoneNumber)
    Fastify API->>Fastify API: Generate secure OTP
    Fastify API->>+Redis: Store OTP with TTL (phoneNumber, otp)
    Redis-->>-Fastify API: Confirmation
    Fastify API->>+Plivo API: Send SMS (to: phoneNumber, text: otp)
    Plivo API-->>-Fastify API: SMS Sent Confirmation / Error
    Fastify API-->>-User: { success: true } / Error

    User->>+Fastify API: POST /verify-otp (phoneNumber, otp)
    Fastify API->>+Redis: Get OTP for phoneNumber
    Redis-->>-Fastify API: Stored OTP / Not Found
    alt OTP Found and Matches
        Fastify API->>Fastify API: Compare user OTP with stored OTP
        Fastify API->>+Redis: Delete OTP (optional, or let TTL expire)
        Redis-->>-Fastify API: Confirmation
        Fastify API-->>-User: { success: true, message: ""Verification successful"" }
    else OTP Not Found or Mismatch
        Fastify API-->>-User: { success: false, message: ""Invalid or expired OTP"" }
    end

Outcome: Functional API with two endpoints: /request-otp to send an OTP to a user's phone and /verify-otp to validate the OTP entered by the user.

Prerequisites:

  • Node.js and npm (or yarn) installed
  • Terminal or command prompt access
  • Plivo account with API credentials (Auth ID, Auth Token) and SMS-enabled phone number
  • Redis server running (locally or accessible via network)
  • Basic understanding of Node.js, APIs, and asynchronous programming

1. How to Set Up Node.js Fastify OTP Project

Initialize the Node.js project, install dependencies, and set up the basic structure.

1. Initialize Project

bash
mkdir fastify-plivo-otp
cd fastify-plivo-otp
npm init -y

2. Install Dependencies:

  • fastify: Core web framework
  • plivo-node: Plivo Node.js SDK
  • redis: Node.js client for Redis
  • otp-generator: Generates secure OTPs
  • @fastify/env: Loads and validates environment variables
  • pino-pretty: (Dev dependency) Human-readable logs during development

Note on Redis Client Version: This guide uses the redis package (v4+ API) which uses promises instead of callbacks. Redis v5 is now available, but the v4 API remains compatible. If migrating from Redis v3, note that legacy mode (createClient({ legacyMode: true })) is available but not recommended for new projects.

Citation2. From source: https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md, Title: Redis Node.js Client v3 to v4 Migration Guide, Text: Legacy mode was introduced in v4 to preserve backwards compatibility while getting access to the updated experience. The modern promise-based API is recommended for all new projects.

bash
# Production Dependencies
npm install fastify plivo-node redis otp-generator @fastify/env

# Development Dependency
npm install --save-dev pino-pretty

3. Project Structure

Create the following directories and files:

text
fastify-plivo-otp/
├── node_modules/
├── src/
│   ├── config/
│   │   └── index.js      # Environment variable loading/validation
│   ├── routes/
│   │   └── otp.js        # API routes for OTP
│   ├── services/
│   │   ├── otpService.js # Logic for OTP generation, storage, verification
│   │   └── plivoService.js # Logic for interacting with Plivo API
│   └── app.js            # Fastify server setup and plugin registration
├── .env                  # Environment variables (DO NOT COMMIT)
├── .env.example          # Example environment variables (Commit this)
├── .gitignore            # Git ignore file
└── package.json

4. Configure .gitignore:

Create a .gitignore file in the root directory to prevent sensitive information and unnecessary files from being committed:

text
# .gitignore
node_modules/
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*

5. Set up Environment Variables:

Create .env.example with the required variable names:

ini
# .env.example
NODE_ENV=development
PORT=3000

# Plivo Credentials
PLIVO_AUTH_ID=
PLIVO_AUTH_TOKEN=
PLIVO_SENDER_NUMBER=

# Redis Connection
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# REDIS_PASSWORD= # Uncomment if your Redis requires a password
# REDIS_DB=0      # Uncomment if using a specific Redis database

# OTP Settings
OTP_LENGTH=6
OTP_VALIDITY_SECONDS=300 # 5 minutes

Create a .env file (which should not be committed) and populate it with your actual Plivo credentials and Redis details. Obtain Plivo credentials from your Plivo Console under ""API"" -> ""Keys & Credentials"". Get your Plivo sender number from ""Phone Numbers"" -> ""Your Numbers"".

6. Configure Environment Loading (src/config/index.js):

We'll use @fastify/env to load and validate our environment variables.

javascript
// src/config/index.js
'use strict';

const fp = require('fastify-plugin');
const fastifyEnv = require('@fastify/env');

const schema = {
  type: 'object',
  required: [
    'PORT',
    'PLIVO_AUTH_ID',
    'PLIVO_AUTH_TOKEN',
    'PLIVO_SENDER_NUMBER',
    'REDIS_HOST',
    'REDIS_PORT',
    'OTP_VALIDITY_SECONDS',
  ],
  properties: {
    NODE_ENV: { type: 'string', default: 'development' },
    PORT: { type: 'number', default: 3000 },
    PLIVO_AUTH_ID: { type: 'string' },
    PLIVO_AUTH_TOKEN: { type: 'string' },
    PLIVO_SENDER_NUMBER: { type: 'string' }, // Should be in E.164 format, e.g., +14155552671
    REDIS_HOST: { type: 'string', default: '127.0.0.1' },
    REDIS_PORT: { type: 'number', default: 6379 },
    REDIS_PASSWORD: { type: 'string', default: null }, // Optional
    REDIS_DB: { type: 'number', default: 0 },         // Optional
    OTP_LENGTH: { type: 'number', default: 6 },
    OTP_VALIDITY_SECONDS: { type: 'number', default: 300 },
  },
};

const options = {
  confKey: 'config', // Access environment variables via `fastify.config`
  schema: schema,
  dotenv: true,      // Load .env file
};

module.exports = fp(async (fastify) => {
  await fastify.register(fastifyEnv, options);
  fastify.log.info('Environment variables loaded successfully.');
});

Note: The fastify-plugin package is required by this configuration module. Install it if not already present:

bash
npm install fastify-plugin

7. Set up Fastify Server (src/app.js):

This file initializes the Fastify instance, registers plugins (like our config loader), sets up logging, and defines routes.

javascript
// src/app.js
'use strict';

const Fastify = require('fastify');
const configLoader = require('./config');
const otpRoutes = require('./routes/otp');
const { initializeRedis } = require('./services/otpService');
const { initializePlivo } = require('./services/plivoService');

async function buildServer() {
  const fastify = Fastify({
    logger: {
      level: process.env.NODE_ENV === 'development' ? 'info' : 'warn',
      transport: process.env.NODE_ENV === 'development'
        ? { target: 'pino-pretty', options: { colorize: true } }
        : undefined,
    },
  });

  try {
    // Register environment variable loader
    await fastify.register(configLoader);

    // Initialize Redis client (make it available globally in fastify instance)
    const redisClient = await initializeRedis(fastify.config, fastify.log);
    fastify.decorate('redis', redisClient);
    fastify.log.info('Redis client initialized and decorated.');

    // Initialize Plivo client
    const plivoClient = initializePlivo(fastify.config, fastify.log);
    fastify.decorate('plivo', plivoClient);
    fastify.log.info('Plivo client initialized and decorated.');

    // Register OTP routes
    await fastify.register(otpRoutes, { prefix: '/api/otp' });
    fastify.log.info('OTP routes registered under /api/otp.');

    // Basic root route
    fastify.get('/', async (request, reply) => {
      return { status: 'ok', timestamp: new Date().toISOString() };
    });

    // Graceful shutdown
    const signals = ['SIGINT', 'SIGTERM'];
    signals.forEach((signal) => {
      process.on(signal, async () => {
        fastify.log.info(`Received ${signal}, shutting down gracefully...`);
        await fastify.close();
        // Explicitly close Redis connection if needed, though Fastify hooks might handle it
        if (fastify.redis && fastify.redis.isOpen) {
           await fastify.redis.quit();
           fastify.log.info('Redis connection closed.');
        }
        process.exit(0);
      });
    });

  } catch (err) {
    fastify.log.error(err, 'Error during server initialization');
    process.exit(1);
  }

  return fastify;
}

module.exports = buildServer;

// Start the server if run directly (e.g., `node src/app.js`)
if (require.main === module) {
  (async () => {
    const server = await buildServer();
    try {
      await server.listen({ port: server.config.PORT, host: '0.0.0.0' });
      // Logger already announces the listening address
    } catch (err) {
      server.log.error(err, 'Error starting server');
      process.exit(1);
    }
  })();
}

8. Add Start Script to package.json:

json
// package.json (add/modify scripts section)
  ""scripts"": {
    ""start"": ""node src/app.js"",
    ""dev"": ""NODE_ENV=development node src/app.js"",
    ""test"": ""echo \""Error: no test specified\"" && exit 1""
  },

At this point, you have the basic project structure, dependencies, configuration loading, and a Fastify server ready. Running npm run dev (after setting up .env) should start the server, although the OTP routes won't work yet.

2. How to Implement OTP Generation and Verification with Redis

Now, let's implement the core logic for generating, storing, and verifying OTPs using Redis.

1. Initialize Redis Client and OTP Functions (src/services/otpService.js):

javascript
// src/services/otpService.js
'use strict';

const redis = require('redis');
const otpGenerator = require('otp-generator');

let redisClient;
let config;
let log;

// Function to initialize and connect the Redis client
async function initializeRedis(appConfig, logger) {
  if (redisClient && redisClient.isOpen) {
    logger.warn('Redis client already initialized.');
    return redisClient;
  }

  config = appConfig; // Store config for later use
  log = logger;       // Store logger

  const redisOptions = {
    socket: {
      host: config.REDIS_HOST,
      port: config.REDIS_PORT,
    },
    // Uncomment and set password if needed
    // password: config.REDIS_PASSWORD,
    database: config.REDIS_DB, // Default is 0
  };

  if (config.REDIS_PASSWORD) {
    redisOptions.password = config.REDIS_PASSWORD;
  }


  log.info(`Attempting to connect to Redis at ${config.REDIS_HOST}:${config.REDIS_PORT}, DB: ${config.REDIS_DB}`);
  redisClient = redis.createClient(redisOptions);

  redisClient.on('error', (err) => log.error('Redis Client Error', err));
  redisClient.on('connect', () => log.info('Redis client connected'));
  redisClient.on('reconnecting', () => log.warn('Redis client reconnecting'));
  redisClient.on('end', () => log.info('Redis client connection closed'));

  try {
    await redisClient.connect();
    log.info('Successfully connected to Redis.');
  } catch (err) {
    log.error('Failed to connect to Redis:', err);
    throw err; // Propagate error to stop server initialization if needed
  }

  return redisClient;
}

// Function to generate a secure OTP
function generateOtp() {
  return otpGenerator.generate(config.OTP_LENGTH, {
    upperCaseAlphabets: false,
    lowerCaseAlphabets: false,
    specialChars: false,
  });
}

// Function to store OTP in Redis with expiry
// Key format: otp:<phoneNumber>
async function storeOtp(phoneNumber, otp) {
  if (!redisClient || !redisClient.isOpen) {
    log.error('Redis client not connected. Cannot store OTP.');
    throw new Error('Internal Server Error: Database connection issue');
  }
  const key = `otp:${phoneNumber}`;
  const validitySeconds = config.OTP_VALIDITY_SECONDS;
  try {
    await redisClient.setEx(key, validitySeconds, otp);
    log.info(`OTP for ${phoneNumber} stored successfully. TTL: ${validitySeconds}s`);
    return true;
  } catch (err) {
    log.error(`Failed to store OTP for ${phoneNumber}:`, err);
    throw new Error('Internal Server Error: Failed to store OTP');
  }
}

// Function to verify OTP against the stored value in Redis
async function verifyOtp(phoneNumber, otpToCheck) {
  if (!redisClient || !redisClient.isOpen) {
    log.error('Redis client not connected. Cannot verify OTP.');
    throw new Error('Internal Server Error: Database connection issue');
  }
  const key = `otp:${phoneNumber}`;
  try {
    const storedOtp = await redisClient.get(key);

    if (!storedOtp) {
      log.warn(`No OTP found for ${phoneNumber} or it has expired.`);
      return false;
    }

    if (storedOtp === otpToCheck) {
      log.info(`OTP verification successful for ${phoneNumber}.`);
      // Optionally delete the key immediately after successful verification
      // await redisClient.del(key);
      // log.info(`OTP key ${key} deleted after successful verification.`);
      return true;
    } else {
      log.warn(`OTP mismatch for ${phoneNumber}.`);
      // Implement rate limiting or attempt tracking here if needed
      return false;
    }
  } catch (err) {
    log.error(`Failed to verify OTP for ${phoneNumber}:`, err);
    throw new Error('Internal Server Error: Failed to verify OTP');
  }
}

module.exports = {
  initializeRedis,
  generateOtp,
  storeOtp,
  verifyOtp,
  // Export client only if absolutely necessary outside this module
  // getRedisClient: () => redisClient,
};

Why Redis? Redis is used here because it's incredibly fast for the simple task of storing a short string (the OTP) associated with a key (the phone number) and automatically deleting it after a set time (Time-To-Live or TTL). This avoids the need for complex database cleanup jobs.

Why otp-generator? Using Math.random() as shown in some simple examples is not cryptographically secure. otp-generator uses Node.js's crypto module, providing much stronger randomness suitable for security purposes.

Secure Alternatives: You can also generate cryptographically secure OTPs using Node.js's built-in crypto.randomBytes() or crypto.randomInt() methods directly, or use libraries like otplib for TOTP/HOTP (time-based or HMAC-based one-time passwords). For production systems requiring higher security, consider implementing TOTP algorithms compatible with authenticator apps.

Citation4. From source: https://dev.to/mahendra_singh_7500/generating-a-secure-6-digit-otp-in-javascript-and-nodejs-2nbo, Title: Generating a Secure 6-Digit OTP in JavaScript and Node.js, Text: Math.random() does not provide cryptographically secure random numbers. Use the crypto module's randomBytes() for security-related purposes. The crypto module ensures OTPs are cryptographically secure.

3. Building the API Layer

Define the API endpoints using Fastify routes and schemas for validation.

1. Create OTP Routes (src/routes/otp.js):

javascript
// src/routes/otp.js
'use strict';

const { generateOtp, storeOtp, verifyOtp } = require('../services/otpService');
const { sendOtpSms } = require('../services/plivoService');

// Schema for request body validation (Request OTP)
const requestOtpSchema = {
  body: {
    type: 'object',
    required: ['phoneNumber'],
    properties: {
      phoneNumber: {
        type: 'string',
        // Basic pattern for E.164 format (starts with +, followed by digits)
        pattern: '^\\+[1-9]\\d{1,14}',
        description: 'User phone number in E.164 format (e.g., +14155552671)',
      },
    },
  },
  response: {
    200: {
      type: 'object',
      properties: {
        success: { type: 'boolean' },
        message: { type: 'string' },
        // Avoid sending OTP back in response for security
      },
    },
    // Define other responses (400, 500) if needed
  },
};

// Route registration function
async function otpRoutes(fastify, options) {
  // Get OTP length from validated config
  const otpLength = fastify.config.OTP_LENGTH;

  // Define schema for Verify OTP *inside* the plugin function
  // This ensures fastify.config is available
  const verifyOtpSchema = {
    body: {
      type: 'object',
      required: ['phoneNumber', 'otp'],
      properties: {
        phoneNumber: {
          type: 'string',
          pattern: '^\\+[1-9]\\d{1,14}',
          description: 'User phone number in E.164 format',
        },
        otp: {
          type: 'string',
          // Use the OTP_LENGTH from config for the pattern
          pattern: `^[0-9]{${otpLength}}$`,
          description: `The ${otpLength}-digit OTP received via SMS`,
        },
      },
    },
    response: {
      200: {
        type: 'object',
        properties: {
          success: { type: 'boolean' },
          message: { type: 'string' },
        },
      },
       // Define other responses (400, 401, 500) if needed
    },
  };


  // Endpoint to request an OTP
  fastify.post('/request', { schema: requestOtpSchema }, async (request, reply) => {
    const { phoneNumber } = request.body;
    const log = request.log; // Use request-specific logger

    try {
      const otp = generateOtp();
      log.info(`Generated OTP ${otp} for ${phoneNumber}`); // Log OTP in dev only if necessary

      // Store OTP in Redis first
      await storeOtp(phoneNumber, otp); // This will throw if Redis fails

      // Then, attempt to send via Plivo
      const messageSid = await sendOtpSms(fastify.plivo, fastify.config, log, phoneNumber, otp);

      log.info(`OTP SMS initiated for ${phoneNumber}. Message SID: ${messageSid}`);
      return reply.code(200).send({ success: true, message: 'OTP sent successfully.' });

    } catch (error) {
      log.error(`Error requesting OTP for ${phoneNumber}: ${error.message}`);
      // Differentiate between Plivo errors and Redis errors if needed
      if (error.message.includes('Plivo') || error.message.includes('SMS')) {
         // Consider specific error codes/messages from Plivo if available
         return reply.code(500).send({ success: false, message: 'Failed to send OTP via SMS. Please try again later.' });
      } else if (error.message.includes('Database') || error.message.includes('store OTP')) {
         return reply.code(500).send({ success: false, message: 'Internal server error. Please try again later.' });
      }
      // Generic fallback
      return reply.code(500).send({ success: false, message: 'An unexpected error occurred.' });
    }
  });

  // Endpoint to verify an OTP
  fastify.post('/verify', { schema: verifyOtpSchema }, async (request, reply) => {
    const { phoneNumber, otp } = request.body;
    const log = request.log;

    try {
      const isValid = await verifyOtp(phoneNumber, otp);

      if (isValid) {
        log.info(`OTP verification successful for ${phoneNumber}`);
        // Here you would typically grant access, issue a token, etc.
        return reply.code(200).send({ success: true, message: 'OTP verified successfully.' });
      } else {
        log.warn(`Invalid or expired OTP provided for ${phoneNumber}`);
        // Return 400 Bad Request as the input OTP is invalid/expired
        return reply.code(400).send({ success: false, message: 'Invalid or expired OTP.' });
      }
    } catch (error) {
      log.error(`Error verifying OTP for ${phoneNumber}: ${error.message}`);
       // Handle potential Redis errors during verification
      if (error.message.includes('Database') || error.message.includes('verify OTP')) {
         return reply.code(500).send({ success: false, message: 'Internal server error. Please try again later.' });
      }
      return reply.code(500).send({ success: false, message: 'An unexpected error occurred during verification.' });
    }
  });
}

module.exports = otpRoutes;

Why Schemas? Fastify's built-in JSON schema validation is highly efficient. It automatically validates incoming request bodies (and can validate params, query strings, headers, and responses) before your handler code runs. This prevents invalid data from reaching your core logic, improving security and reducing boilerplate validation code. The schemas also serve as implicit documentation for your API endpoints. By defining the verifyOtpSchema inside the otpRoutes function, we ensure access to the validated fastify.config to set the correct OTP length dynamically.

4. How to Integrate Plivo SMS API for OTP Delivery

Now, let's set up the Plivo client and the function to send the SMS.

1. Initialize Plivo Client and Sending Function (src/services/plivoService.js):

javascript
// src/services/plivoService.js
'use strict';

const plivo = require('plivo');

let plivoClient;
let config;
let log;

// Function to initialize the Plivo client
function initializePlivo(appConfig, logger) {
  if (plivoClient) {
    logger.warn('Plivo client already initialized.');
    return plivoClient;
  }

  config = appConfig; // Store config for use in sendOtpSms
  log = logger;       // Store logger

  log.info('Initializing Plivo client...');
  try {
    // Ensure Auth ID and Token are strings
    const authId = String(config.PLIVO_AUTH_ID);
    const authToken = String(config.PLIVO_AUTH_TOKEN);
    plivoClient = new plivo.Client(authId, authToken);
    log.info('Plivo client initialized successfully.');
    return plivoClient;
  } catch (error) {
    log.error('Failed to initialize Plivo client:', error);
    throw error; // Stop server initialization if Plivo setup fails
  }
}

// Function to send OTP via Plivo SMS API
async function sendOtpSms(client, appConfig, logger, destinationNumber, otp) {
  // Use passed-in client, config, logger for better testability and context
  const senderNumber = appConfig.PLIVO_SENDER_NUMBER;
  const messageText = `Your verification code is: ${otp}`;

  logger.info(`Attempting to send OTP SMS from ${senderNumber} to ${destinationNumber}`);

  try {
    const response = await client.messages.create(
      senderNumber,     // src
      destinationNumber, // dst
      messageText       // text
    );

    // Check Plivo response structure. The Plivo REST API returns message_uuid (underscore),
    // but the Node.js SDK (v4+) converts this to camelCase as messageUuid.
    // The response is an array of message UUIDs, typically containing one element for single messages.
    if (response && response.messageUuid && Array.isArray(response.messageUuid) && response.messageUuid.length > 0) {
      const messageSid = response.messageUuid[0];
      logger.info(`Plivo SMS sent successfully. Message UUID: ${messageSid}`);
      return messageSid; // Return the message identifier
    } else {
       // Log the actual response structure if it deviates from expectation.
       logger.warn('Plivo response structure unexpected or message UUID missing/invalid.', { plivoResponse: response });
       // Consider this potentially successful but indicate logging is needed.
       return 'Unknown UUID - Check Plivo Logs';
    }
  } catch (error) {
    // Plivo SDK errors often have useful details
    logger.error(`Plivo API Error sending SMS to ${destinationNumber}: ${error.message}`, {
        statusCode: error.statusCode, // Plivo errors might include status codes
        errorDetails: error.error // Plivo might provide more detailed error object
    });
    // Re-throw a more specific error or a generic one
    throw new Error(`Plivo SMS sending failed: ${error.message}`);
  }
}


module.exports = {
  initializePlivo,
  sendOtpSms,
};

Obtaining Plivo Credentials:

  1. Log in to your Plivo Console.
  2. Navigate to the ""API"" section in the left-hand menu, then select ""Keys & Credentials"".
  3. Your Auth ID and Auth Token are displayed here. Copy these securely into your .env file.
    • PLIVO_AUTH_ID=YOUR_AUTH_ID
    • PLIVO_AUTH_TOKEN=YOUR_AUTH_TOKEN
  4. Navigate to ""Phone Numbers"" -> ""Your Numbers"".
  5. Find an SMS-enabled number you have rented. Copy the full number, including the country code (E.164 format, e.g., +14155551234). This is your sender number.
    • PLIVO_SENDER_NUMBER=+14155551234

Environment Variable Explanation:

  • PLIVO_AUTH_ID: Your unique Plivo account identifier for API authentication.
  • PLIVO_AUTH_TOKEN: Your secret API token for authentication. Treat this like a password.
  • PLIVO_SENDER_NUMBER: The Plivo phone number (in E.164 format) from which the SMS OTP messages will be sent.

5. Implementing Error Handling and Logging

We've already incorporated basic error handling and logging, but let's refine it.

  • Logging: We configured pino-pretty for readable development logs and standard JSON logs (suitable for log aggregators) in production within src/app.js. Fastify automatically assigns a unique request.log instance to each request, providing context. We use log.info, log.warn, and log.error appropriately in our services and routes.
  • Error Handling Strategy:
    • Validation Errors: Fastify schemas handle bad requests (400) automatically.
    • Service Errors: otpService.js and plivoService.js throw errors on critical failures (e.g., Redis connection down, Plivo API errors).
    • Route Handlers: The try...catch blocks in src/routes/otp.js catch errors from services. They attempt to differentiate between internal errors (Redis) and external service errors (Plivo) to provide slightly more context in the 500 response, although user-facing messages remain generic for security. Verification failures (wrong/expired OTP) result in a 400 Bad Request.
    • Fastify Global Handler: For unhandled errors, Fastify has a default error handler. You could customize this using fastify.setErrorHandler(async (error, request, reply) => { ... }) in app.js for more advanced global error logging or formatting.

Example: Adding a Custom Global Error Handler (Optional):

javascript
// src/app.js (inside buildServer, after plugin registration)

fastify.setErrorHandler(async (error, request, reply) => {
  request.log.error({ err: error }, 'Unhandled error occurred!');

  // Example: Hide internal details in production
  if (fastify.config.NODE_ENV !== 'development' && !error.statusCode) {
     // Default to 500 if no specific status code is set by the error
     reply.code(500).send({ success: false, message: 'An internal server error occurred.' });
     return;
  }

  // Use error's status code if available, otherwise default to 500
  const statusCode = error.statusCode || 500;
  // Ensure message is user-friendly, especially for validation errors
  const message = error.validation ? 'Invalid input provided.' : (error.message || 'An error occurred.');

  reply.code(statusCode).send({
      success: false,
      message: message,
      // Optionally include error code or validation details in development
      ...(fastify.config.NODE_ENV === 'development' && {
          code: error.code,
          validation: error.validation,
          stack: error.stack
      })
  });
});

Retry Mechanisms:

Implementing retries, especially for external API calls like Plivo, can improve resilience. However, it adds complexity.

  • Simple Retry: You could wrap the sendOtpSms call in a simple loop with delays.
  • Exponential Backoff: Use libraries like async-retry or p-retry for more robust retry logic with increasing delays between attempts. This prevents overwhelming the external service during transient outages.

For this guide, we'll omit complex retry code, but acknowledge its importance in production. Retrying Redis operations is generally less critical if the connection is stable, but could be considered. Be cautious not to retry operations that shouldn't be duplicated (like sending multiple OTPs). The request-store-send sequence helps mitigate duplicate sends if the Plivo call fails initially but a subsequent request succeeds.

6. Database Schema and Data Layer

In this specific implementation, we are intentionally not using a traditional relational database (like PostgreSQL or MySQL) for the core OTP flow.

  • User Data: We assume user data (like profile information, hashed passwords) is stored elsewhere in your main application database. This OTP service focuses solely on the verification step.
  • OTP Storage: Redis serves as our data layer for temporary OTP storage.
    • Schema: Key-value based.
      • Key: otp:<phoneNumberInE.164Format> (e.g., otp:+14155552671)
      • Value: The generated OTP string (e.g., ""123456"")
      • TTL (Time-To-Live): Set via SETEX command (e.g., 300 seconds). Redis automatically deletes the key after the TTL expires.
    • Data Access: Handled by storeOtp and verifyOtp functions in src/services/otpService.js using the redis client library.
    • Migrations: Not applicable for this Redis usage pattern. Schema is implicit in the key structure.
    • Performance: Redis is extremely fast for this type of workload (simple GET/SET/DEL operations). Ensure your Redis instance has sufficient memory and network bandwidth.

If you needed to persist verification attempts or link verifications to user accounts permanently, you would integrate with your main application database, potentially adding tables like otp_verifications (user_id, phone_number, timestamp, success_status). However, for the basic OTP flow, Redis is sufficient and often preferred for performance.

7. Adding Security Features

Security is paramount for authentication systems.

  • Input Validation: Already implemented using Fastify's schemas (requestOtpSchema, verifyOtpSchema) in src/routes/otp.js. This prevents malformed requests and basic injection attempts. The E.164 phone number pattern and OTP format pattern add specific constraints.
  • Secure OTP Generation: Using otp-generator ensures cryptographically secure random numbers, making OTPs hard to guess.
  • Secure OTP Storage: Storing OTPs temporarily in Redis with a short TTL (e.g., 5 minutes) minimizes the window of opportunity for attackers if Redis is somehow compromised. Ensure Redis is properly secured (network access, password).
  • Transport Security: Always run your application behind HTTPS in production to encrypt data in transit between the user and your API, and ideally between your API and Plivo/Redis if they are on different networks.
  • Rate Limiting: Crucial to prevent brute-force attacks on both requesting OTPs (SMS Pumping/Toll Fraud) and verifying OTPs.
    • Implementation: Use @fastify/rate-limit.
    bash
    npm install @fastify/rate-limit
    • Configuration (src/app.js):
    javascript
    // src/app.js (inside buildServer, register before routes)
    const fastifyRateLimit = require('@fastify/rate-limit');
    
    // ... inside buildServer try block ...
    await fastify.register(fastifyRateLimit, {
      max: 5, // Max requests per window per key
      timeWindow: '1 minute', // Time window
      redis: fastify.redis, // Use existing Redis client for distributed rate limiting
      keyGenerator: (request) => {
        // Rate limit based on phone number for OTP actions, or IP for others
        if (request.body && request.body.phoneNumber) {
          return request.body.phoneNumber;
        }
        // Fallback to IP address for other routes or if phone number not present
        return request.ip;
      },
      enableDraftSpec: true, // Use standard RateLimit headers
      errorResponseBuilder: (req, context) => {
        return {
           success: false,
           message: `Rate limit exceeded. Please try again after ${Math.ceil(context.ttl / 1000)} seconds.`,
           code: 'RATE_LIMIT_EXCEEDED',
           retryAfter: context.ttl, // Time to wait in milliseconds
        }
      },
      // Allow list IPs if necessary
      // allowList: ['127.0.0.1']
    });
    fastify.log.info('Rate limiting enabled.');
    // ... rest of plugin/route registration ...
    • Explanation: This configuration limits requests keyed by phone number (for OTP endpoints) to 5 per minute. Using Redis makes the rate limiting effective across multiple instances of your application if deployed in a cluster.
  • Secret Management: API keys and passwords (PLIVO_AUTH_TOKEN, REDIS_PASSWORD) are stored in .env and loaded securely. Never commit .env files. Use environment variables or a secrets management system (like HashiCorp Vault, AWS Secrets Manager, etc.) in production environments.

Frequently Asked Questions

How secure is SMS OTP for two-factor authentication?

SMS OTP provides moderate security but has known vulnerabilities including SIM swapping, SS7 protocol exploits, and phishing attacks. NIST discouraged SMS-based 2FA since 2016. For high-security applications, use FIDO2/Passkeys or TOTP authenticator apps instead. SMS OTP remains acceptable for low-to-medium risk applications where convenience is prioritized, or as a fallback method. Any MFA is better than no MFA.

How do I generate cryptographically secure OTPs in Node.js?

Use the otp-generator package (which uses Node.js's crypto module) or Node.js's built-in crypto.randomBytes() or crypto.randomInt() methods directly. Never use Math.random() for security-related OTP generation, as it's not cryptographically secure. For time-based OTPs (TOTP), use libraries like otplib that implement HOTP/TOTP algorithms compatible with authenticator apps.

Why use Redis for OTP storage instead of a database?

Redis is ideal for OTP storage because it's extremely fast for simple key-value operations and supports automatic key expiration (TTL). This eliminates the need for cleanup jobs to delete expired OTPs. Redis can handle millions of SET/GET operations per second, making it perfect for high-traffic authentication systems. For permanent audit trails, use Redis for OTP validation and log successful verifications to your primary database.

How long should an OTP be valid?

The standard OTP validity period is 5 minutes (300 seconds), balancing security and user convenience. Shorter periods (2-3 minutes) provide better security but may frustrate users with slow SMS delivery. Longer periods (10+ minutes) increase vulnerability to brute-force attacks and interception. Configure this via the OTP_VALIDITY_SECONDS environment variable in the example code.

What is the difference between OTP and TOTP?

OTP (One-Time Password) is a generic term for any single-use password. TOTP (Time-based One-Time Password) is a specific OTP algorithm that generates codes based on the current time and a shared secret, standardized in RFC 6238. TOTP codes expire after 30 seconds and are generated locally by authenticator apps (Google Authenticator, Authy), making them more secure than SMS OTPs because they don't require network transmission.

How do I get Plivo API credentials?

Log in to your Plivo Console, navigate to "API" → "Keys & Credentials" to find your Auth ID and Auth Token. Then go to "Phone Numbers" → "Your Numbers" to get an SMS-enabled phone number in E.164 format (e.g., +14155551234). Add these to your .env file as PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, and PLIVO_SENDER_NUMBER.

How do I prevent SMS pumping and toll fraud?

Implement rate limiting using @fastify/rate-limit to restrict OTP requests per phone number (e.g., 5 per minute). Add CAPTCHA on the OTP request endpoint to prevent automated attacks. Monitor unusual patterns (many OTPs to international numbers, repeated failed verifications). Consider geofencing to restrict OTP delivery to specific countries. Implement phone number verification before allowing OTP requests.

Can I use this OTP system in production?

Yes, with additional hardening: enable HTTPS/TLS for all connections, implement comprehensive rate limiting and CAPTCHA, secure Redis with password authentication and network restrictions, monitor for unusual patterns and implement alerting, add logging and audit trails for compliance, implement backup verification methods (email, security questions), and regularly review OWASP security guidelines. Consider using TOTP authenticator apps as the primary 2FA method with SMS OTP as a fallback.

Frequently Asked Questions

How to implement 2FA with Node.js and Fastify?

Implement 2FA by integrating SMS OTP using Node.js with the Fastify framework and the Plivo SMS API. This involves setting up routes for requesting and verifying OTPs, generating secure OTPs, and storing them temporarily in Redis with an expiration time for enhanced security.

What is Plivo used for in 2FA?

Plivo is a cloud communications platform that provides the SMS API for sending OTPs to users' phones as the second factor of authentication. It's chosen for its reliable message delivery and easy-to-use API integration.

Why use Redis for storing OTPs?

Redis, an in-memory data store, is ideal for storing OTPs temporarily due to its speed and automatic expiration feature (TTL). It efficiently handles short-lived data like OTPs, avoiding complex database cleanup processes.

When should I implement rate limiting in OTP verification?

Implement rate limiting for both OTP requests and verifications to protect against brute-force attacks. This prevents attackers from repeatedly trying different OTPs or flooding users with SMS messages (SMS pumping).

Can I use Math.random() for OTP generation?

No, `Math.random()` isn't cryptographically secure. Use libraries like `otp-generator`, which utilizes Node.js's `crypto` module for stronger randomness suitable for security-sensitive applications.

How to set up Plivo for sending SMS OTPs?

Obtain your Auth ID, Auth Token, and an SMS-enabled Plivo number from the Plivo Console. Store these securely as environment variables (`PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_SENDER_NUMBER`) and use them to initialize the Plivo Node.js SDK in your application.

What is the purpose of Fastify schemas in this project?

Fastify schemas perform request validation before reaching your core logic. This enhances security by blocking malformed requests and potential injection attempts. Schemas also implicitly document the API.

How does the OTP system handle errors?

The system uses try-catch blocks, logging, and Fastify schemas for handling errors. Schemas handle validation errors (400 Bad Request), and try-catch blocks within services and routes manage service-specific and unexpected errors (500 Internal Server Error).

What is the architecture of the 2FA implementation?

The architecture involves a user interacting with a Fastify API. The API generates an OTP, stores it in Redis, and uses Plivo to send it via SMS to the user. Verification happens against the Redis-stored OTP.

Why does the project use Fastify?

Fastify is a high-performance Node.js web framework chosen for speed and extensibility. It features built-in schema validation and a developer-friendly API, contributing to efficient and maintainable code.

How to request and verify an OTP?

Request an OTP by sending a POST request to the `/request-otp` endpoint with the user's phone number. Verify the received OTP with a POST request to `/verify-otp`, including the phone number and OTP.

Where should OTPs be stored?

OTPs are stored temporarily in Redis with a short Time-To-Live (TTL). This provides fast access and automatic deletion after expiration, ensuring they don't persist longer than necessary.

What are the prerequisites for this project?

You need Node.js and npm (or yarn), a terminal, a Plivo account with API credentials, a running Redis server, and basic knowledge of Node.js, APIs, and asynchronous programming.

What technologies are used in this project?

This project leverages Node.js, Fastify, Plivo, Redis, the `otp-generator` library, and environment variable management tools like `dotenv` or `@fastify/env`.