code examples

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

How to Build SMS OTP 2FA with Vonage, Node.js & Fastify: Complete 2025 Guide

Learn how to implement SMS OTP two-factor authentication using Vonage Verify API with Node.js and Fastify. Step-by-step tutorial with security best practices, rate limiting, and production deployment guidance.

SMS OTP two-factor authentication (2FA) adds critical security by sending One-Time Passwords to users' mobile devices during login or sensitive operations. This comprehensive guide teaches you how to implement production-ready SMS OTP verification using Node.js, the high-performance Fastify framework, and the Vonage Verify API v2. You'll learn security best practices, implement rate limiting, handle errors gracefully, and write automated tests for reliable authentication.

Build a complete backend API with two secure endpoints: one to request an OTP code sent via SMS to a user's phone number in E.164 format, and another to verify the OTP code entered by the user.

What Are You Building with Vonage and Fastify?

What You're Building:

A backend API service built with Node.js and Fastify that:

  1. Accepts a user's phone number.
  2. Uses the Vonage Verify API to send an SMS containing a unique OTP to that number.
  3. Provides an endpoint to verify the OTP submitted by the user against the one sent by Vonage.

Problem Solved:

This implementation addresses the need for secure, out-of-band verification commonly used during:

  • User registration: Verify phone numbers before creating accounts
  • Login authentication: Add a second factor beyond passwords
  • Password resets: Confirm identity before allowing password changes
  • Transaction authorization: Verify high-value or sensitive actions
  • Account recovery: Validate users when they lose access credentials

Technologies Used:

  • Node.js: JavaScript runtime environment for building server-side applications.
  • Fastify: High-performance, low-overhead web framework for Node.js. Choose this for its speed, extensibility, and developer-friendly features like built-in validation and logging.
  • Vonage Verify API: Service that handles OTP generation, delivery (SMS, voice), and verification logic. Use this to avoid building and maintaining your own OTP system.
  • dotenv: Module to load environment variables from a .env file into process.env.
  • @vonage/server-sdk: Official Vonage Node.js SDK for interacting with Vonage APIs.

System Architecture:

Your system follows this flow:

StepComponentAction
1UserProvides phone number
2Client App → Fastify APISends phone number to /request-otp
3Fastify API → VonageRequests OTP generation and delivery
4Vonage → UserSends SMS with OTP code
5User → Client AppEnters received OTP code
6Client App → Fastify APISends OTP code and request ID to /verify-otp
7Fastify API → VonageVerifies the OTP code
8Vonage → Fastify APIConfirms or denies verification
9Fastify API → Client AppReturns verification result
10Client App → UserDisplays success or error message

Prerequisites:

  • Node.js and npm (or yarn): Ensure Node.js v20 or later is installed (Fastify v5 requires Node.js v20+ for native async hooks and performance optimizations). Download Node.js
  • Vonage API Account: Required to get API credentials. Sign up for Vonage.
  • Vonage API Key and Secret: Found on your Vonage API Dashboard after signing up.
  • (Optional) Vonage CLI: Useful for managing Vonage applications and numbers. Install via npm install -g @vonage/cli.
  • Basic understanding of Node.js, APIs, and asynchronous JavaScript.

Technology Versions Used in This Guide:

  • Fastify: v5.x (latest stable release as of 2025, requires Node.js v20+)
  • @vonage/server-sdk: v3.24+ (latest as of October 2025)
  • Node.js: v20+ recommended for best compatibility

Note on Vonage Verify API: This guide uses the Vonage Verify v2 API, which supports multiple authentication channels including SMS, Voice, Email, WhatsApp, and Silent Authentication. The API automatically handles OTP generation, delivery, expiration, and verification logic.

Important Security Considerations:

  • Phone Number Format: Always use E.164 format for phone numbers (e.g., +14155552671 for US numbers, +442071234567 for UK numbers). The Vonage API expects this international format and may reject improperly formatted numbers. Common format errors: missing + prefix, including spaces or dashes, omitting country code.
  • Vonage Built-in Protections: The Vonage Verify API includes built-in fraud protection, monitoring for unusual traffic patterns and automatically implementing security measures. The API also enforces its own rate limits to prevent abuse.
  • Cost Awareness: Each OTP request costs approximately $0.06–$0.10 USD depending on destination country. Implement proper rate limiting and validation to prevent malicious actors from generating excessive verification requests. Consider adding CAPTCHA before OTP requests for high-risk endpoints.
  • OTP Expiration: Vonage automatically handles OTP expiration (typically 5–10 minutes depending on workflow). Expired codes cannot be verified, requiring users to request a new OTP.

Sources: Fastify Official Site, Vonage Server SDK npm, Vonage Verify API Documentation, Vonage Verify v2 Fraud Protection, accessed October 2025.

Expected Outcome:

A functional Fastify API service capable of sending and verifying SMS OTPs via Vonage, ready for integration into a larger application. The service will include basic security, error handling, and logging.

How Does SMS OTP 2FA Work?

OTP Generation and Security:

The Vonage Verify API generates cryptographically secure random codes (typically 4–6 digits) that are valid for a limited time window. This approach provides:

  • Time-based expiration: Codes become invalid after 5–10 minutes, limiting the window for attacks
  • Single-use tokens: Each code can only be verified once, even if correct
  • Attempt throttling: After 3–5 failed verification attempts, the request is blocked
  • Out-of-band delivery: Sending codes via SMS separates the authentication channel from the primary application channel, making phishing harder

Verification Flow:

  1. Your API requests an OTP from Vonage with a phone number
  2. Vonage generates a random code and stores it with a unique request_id
  3. Vonage sends the SMS containing the code to the user's phone
  4. User receives the SMS and enters the code in your application
  5. Your API sends the request_id and user-entered code to Vonage
  6. Vonage compares the submitted code against the stored code
  7. Vonage returns success (status 0) if the code matches and is still valid, or an error status if it doesn't match, expired, or was already used

Security Principles:

  • Possession factor: The user must possess the physical device receiving the SMS
  • Short-lived credentials: Time limits reduce replay attack windows
  • Rate limiting: Both your API and Vonage implement rate limits to prevent brute-force attacks
  • Audit trail: All verification attempts are logged for security monitoring

How Do You Set Up a Fastify Project for OTP Verification?

Initialize your Node.js project and install Fastify along with necessary dependencies.

1. Create Project Directory:

Open your terminal and create a new directory for the project, then navigate into it.

bash
mkdir fastify-vonage-otp
cd fastify-vonage-otp

2. Initialize npm Project:

bash
npm init -y

This creates a package.json file with default settings.

3. Install Dependencies:

Install Fastify, the Vonage SDK, dotenv for environment variables, and pino-pretty for development logging.

bash
npm install fastify @vonage/server-sdk dotenv pino-pretty
  • fastify: The core web framework.
  • @vonage/server-sdk: The official Vonage Node.js library.
  • dotenv: Loads environment variables from a .env file.
  • pino-pretty: Makes Fastify's default JSON logs human-readable during development.

4. Configure package.json for Development:

Add a dev script to package.json to run the server with readable logs using pino-pretty.

json
{
  "name": "fastify-vonage-otp",
  "version": "1.0.0",
  "description": "",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node src/server.js | pino-pretty",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vonage/server-sdk": "^...",
    "dotenv": "^...",
    "fastify": "^...",
    "pino-pretty": "^..."
  }
}

(Note: Replace ^... with actual installed versions if needed, but npm install handles this.)

5. Create Project Structure:

Organize the project for clarity:

bash
mkdir src
mkdir src/routes
touch src/server.js
touch .env
touch .gitignore
  • src/: Contains the main application code.
  • src/routes/: Will hold our API route definitions.
  • src/server.js: The main entry point for the Fastify application.
  • .env: Stores sensitive information like API keys (DO NOT commit this file).
  • .gitignore: Specifies intentionally untracked files that Git should ignore.

6. Configure .gitignore:

Add node_modules and .env to your .gitignore file to prevent committing them.

plaintext
# .gitignore

node_modules/
.env
*.log

7. Set Up Environment Variables:

Open the .env file and add your Vonage API credentials and a brand name for the SMS message.

plaintext
# .env

VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME="YourAppName" # Appears in SMS as "Your YourAppName code is: 1234". Keep under 11 characters to avoid SMS segmentation.
PORT=3000 # Optional: Define the port for the server

Replace YOUR_API_KEY and YOUR_API_SECRET with the actual credentials from your Vonage API Dashboard.

8. Basic Fastify Server Setup:

Create the initial server configuration in src/server.js.

javascript
// src/server.js

// Load environment variables early
require('dotenv').config();

const fastify = require('fastify')({
  logger: true // Enable built-in Pino logger
});

// --- Plugin Registration ---
// Example: Register formbody parser if needed later for simpler forms
// fastify.register(require('@fastify/formbody'));

// --- Vonage Client Setup (Placeholder) ---
// We will add Vonage initialization here later

// --- Route Registration (Placeholder) ---
// We will register our OTP routes here

// --- Error Handling (Placeholder) ---
// We will add custom error handling here

// --- Start Server ---
const start = async () => {
  try {
    const port = process.env.PORT || 3000;
    await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
    fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Explanation:

  • require('dotenv').config();: Loads variables from .env into process.env. Crucially done before other modules might need them.
  • fastify({ logger: true }): Initializes Fastify with logging enabled. Pino is used by default.
  • fastify.listen(): Starts the server. We listen on 0.0.0.0 to make it accessible within Docker containers or VMs if needed later.

Run npm run dev in your terminal. You should see log output indicating the server is running, likely on port 3000. Stop the server with Ctrl+C.

How Do You Integrate the Vonage Verify API?

Initialize the Vonage SDK client and make it available within your Fastify application.

1. Initialize Vonage Client:

Update src/server.js to create and configure the Vonage client using the environment variables. We'll use Fastify's decorate utility to make the client accessible within route handlers via request.vonage or fastify.vonage.

javascript
// src/server.js
require('dotenv').config();

const fastify = require('fastify')({
  logger: true
});
const { Vonage } = require('@vonage/server-sdk'); // Import Vonage

// --- Validate Environment Variables ---
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
  console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
  process.exit(1);
}

// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));

// --- Vonage Client Setup ---
const vonage = new Vonage({
  apiKey: process.env.VONAGE_API_KEY,
  apiSecret: process.env.VONAGE_API_SECRET
});

// Decorate Fastify instance/request with the Vonage client
fastify.decorate('vonage', vonage);

// --- Route Registration (Placeholder) ---
// We will register our OTP routes here

// --- Error Handling (Placeholder) ---
// We will add custom error handling here

// --- Start Server ---
const start = async () => {
  // ... (rest of the start function remains the same)
  try {
    const port = process.env.PORT || 3000;
    await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
    fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Explanation:

  • We import the Vonage class from the SDK.
  • We validate that required environment variables exist before instantiating the Vonage client. This prevents cryptic runtime errors.
  • We instantiate Vonage using the API key and secret loaded from .env.
  • fastify.decorate('vonage', vonage) adds the vonage instance to Fastify's application context, making it easily accessible in routes and plugins.

How Do You Implement the OTP Request and Verification Flow?

Create two API endpoints within a dedicated route file:

  • POST /request-otp: Initiates the OTP process.
  • POST /verify-otp: Verifies the submitted OTP.

1. Create Route File:

Create a new file src/routes/otp.js.

bash
touch src/routes/otp.js

2. Define Routes:

Add the route logic to src/routes/otp.js. Use Fastify's schema validation for request bodies.

javascript
// src/routes/otp.js

async function otpRoutes(fastify, options) {

  // Schema for the /request-otp endpoint body
  const requestOtpSchema = {
    body: {
      type: 'object',
      required: ['phoneNumber'],
      properties: {
        phoneNumber: { type: 'string', description: 'User phone number in E.164 format (e.g., +14155552671)' }
      },
      additionalProperties: false // Disallow extra fields
    }
  };

  // Schema for the /verify-otp endpoint body
  const verifyOtpSchema = {
    body: {
      type: 'object',
      required: ['requestId', 'code'],
      properties: {
        requestId: { type: 'string', description: 'The request ID received from /request-otp' },
        code: { type: 'string', minLength: 4, maxLength: 6, description: 'The OTP code entered by the user' }
      },
      additionalProperties: false // Disallow extra fields
    }
  };

  // --- Endpoint: Request OTP ---
  fastify.post('/request-otp', { schema: requestOtpSchema }, async (request, reply) => {
    const { phoneNumber } = request.body;
    const vonage = fastify.vonage; // Access Vonage client via decorator
    const brand = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Use brand from .env or default

    request.log.info(`Requesting OTP for phone number: ${phoneNumber}`);

    try {
      const response = await vonage.verify.start({
        number: phoneNumber,
        brand: brand,
        // code_length: '6' // Optional: Uncomment to request a 6-digit code (default is 4)
        // workflow_id: 6 // Optional: Controls delivery method. Default (1): SMS only. 6: SMS → Voice → Voice for better delivery.
      });

      if (response.status === '0') {
        request.log.info(`OTP request successful, requestId: ${response.request_id}`);
        return reply.send({ success: true, requestId: response.request_id });
      } else {
        // Non-zero status indicates an error from Vonage before sending
        request.log.error(`Vonage verify start error for ${phoneNumber}: Status ${response.status}${response.error_text}`);
        return reply.status(500).send({ success: false, message: response.error_text || 'Failed to initiate OTP verification.' });
      }

    } catch (error) {
      request.log.error({ err: error, phoneNumber }, 'Error calling Vonage verify start API');
      // Handle network errors or SDK issues
      return reply.status(500).send({ success: false, message: 'An internal server error occurred while requesting OTP.' });
    }
  });

  // --- Endpoint: Verify OTP ---
  fastify.post('/verify-otp', { schema: verifyOtpSchema }, async (request, reply) => {
    const { requestId, code } = request.body;
    const vonage = fastify.vonage; // Access Vonage client

    request.log.info(`Verifying OTP for requestId: ${requestId}`);

    try {
      const response = await vonage.verify.check(requestId, code);

      if (response.status === '0') {
        // Status '0' means successful verification
        request.log.info(`OTP verification successful for requestId: ${requestId}`);
        return reply.send({ success: true, message: 'OTP verified successfully.' });
      } else {
        // Handle specific Vonage error statuses
        request.log.warn(`OTP verification failed for requestId ${requestId}: Status ${response.status}${response.error_text}`);
        let userMessage = 'OTP verification failed.';
        let statusCode = 400; // Bad Request by default for verification failures

        // Customize messages based on common statuses (refer to Vonage docs for full list)
        switch (response.status) {
          case '6': // The code provided does not match the expected value
            userMessage = 'Invalid or incorrect OTP code provided.';
            break;
          case '16': // The request specified by the request_id has already been verified
            userMessage = 'This request has already been verified.';
            statusCode = 409; // Conflict
            break;
          case '17': // The wrong code was provided too many times
            userMessage = 'Too many incorrect attempts. Request a new OTP.';
            break;
          case '101': // No response found (e.g., expired request_id)
             userMessage = 'Verification request not found or expired. Request a new OTP.';
             statusCode = 404; // Not Found
             break;
          default:
            userMessage = response.error_text || 'OTP verification failed.';
        }
        return reply.status(statusCode).send({ success: false, message: userMessage, errorCode: response.status });
      }

    } catch (error) {
      // Handle potential SDK errors or network issues during check
      request.log.error({ err: error, requestId }, 'Error calling Vonage verify check API');
       // Check if the error is a Vonage specific error structure (might vary by SDK version)
       // Note: The structure of Vonage SDK errors (`error.body`) can change between versions. Always consult the documentation for the specific SDK version you are using.
       if (error.body && error.body.status && error.body.error_text) {
           request.log.error(`Vonage API Error during check: Status ${error.body.status}${error.body.error_text}`);
           return reply.status(error.statusCode || 500).send({ success: false, message: error.body.error_text || 'An internal error occurred during OTP verification.' });
       }
      return reply.status(500).send({ success: false, message: 'An internal server error occurred during OTP verification.' });
    }
  });
}

module.exports = otpRoutes;

Explanation:

  • otpRoutes(fastify, options): Standard Fastify plugin structure.
  • Schemas (requestOtpSchema, verifyOtpSchema): Define the expected structure and types for the request bodies. Fastify automatically validates incoming requests against these schemas and returns a 400 Bad Request error if validation fails. additionalProperties: false prevents unexpected fields.
  • /request-otp:
    • Retrieves phoneNumber from the validated request.body.
    • Accesses the decorated vonage client.
    • Calls vonage.verify.start() with the phone number and brand name.
    • Crucially: Checks the response.status. A status of '0' means Vonage accepted the request and will attempt to send the SMS. Other statuses mean an error occurred before sending (e.g., invalid number format, throttling).
    • Returns the requestId on success, which the client needs for the verification step.
    • Includes basic try...catch for network/SDK errors.
  • /verify-otp:
    • Retrieves requestId and code from the validated request.body.
    • Calls vonage.verify.check() with the requestId and the user-provided code.
    • Checks response.status: '0' means the code was correct.
    • Handles common non-zero statuses (invalid code, already verified, too many attempts, expired) by returning appropriate HTTP status codes (400, 409, 404) and user-friendly error messages.
    • Includes try...catch for network/SDK errors during the check.

For a complete list of Vonage Verify API error status codes, see the Vonage Verify API Error Codes Reference.

3. Register Routes in Server:

Register these routes in src/server.js.

javascript
// src/server.js
require('dotenv').config();

const fastify = require('fastify')({
  logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp'); // Import the routes

// --- Validate Environment Variables ---
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
  console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
  process.exit(1);
}

// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));

// --- Vonage Client Setup ---
const vonage = new Vonage({
  apiKey: process.env.VONAGE_API_KEY,
  apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);

// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' }); // Register OTP routes under /api/v1 prefix

// --- Error Handling (Placeholder) ---
// We will add custom error handling here

// --- Start Server ---
const start = async () => {
 // ... (start function)
 try {
    const port = process.env.PORT || 3000;
    await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
    fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Explanation:

  • We import the otpRoutes function.
  • fastify.register(otpRoutes, { prefix: '/api/v1' }): Registers all routes defined in otp.js under the /api/v1 path prefix (e.g., /api/v1/request-otp). Using a prefix is good practice for API versioning.

At this point, you have the core API functionality. Test it using curl or a tool like Postman after starting the server (npm run dev).

Testing with curl:

Replace +1XXXXXXXXXX with a real phone number you can receive SMS on (use E.164 format).

  1. Request OTP:

    bash
    curl -X POST http://localhost:3000/api/v1/request-otp \
      -H 'Content-Type: application/json' \
      -d '{"phoneNumber": "+1XXXXXXXXXX"}'

    Expected Response (Success):

    json
    {"success":true,"requestId":"YOUR_UNIQUE_REQUEST_ID"}

    You should receive an SMS with a 4-digit code (by default).

  2. Verify OTP: Replace YOUR_UNIQUE_REQUEST_ID with the ID from the previous step and YOUR_OTP_CODE with the code from the SMS.

    bash
    curl -X POST http://localhost:3000/api/v1/verify-otp \
      -H 'Content-Type: application/json' \
      -d '{"requestId": "YOUR_UNIQUE_REQUEST_ID", "code": "YOUR_OTP_CODE"}'

    Expected Response (Success):

    json
    {"success":true,"message":"OTP verified successfully."}

    Expected Response (Incorrect Code):

    json
    {"success":false,"message":"Invalid or incorrect OTP code provided.","errorCode":"6"}

    (Note: The HTTP status code will be 400 for incorrect code)

Common Testing Issues:

IssueCauseSolution
SMS not receivedInvalid phone number formatVerify E.164 format with country code
Error 401 or 403Invalid API credentialsCheck .env file has correct credentials
Error 429Rate limit exceededWait before making more requests
Connection timeoutNetwork or firewall issueCheck internet connection and firewall rules
Verification fails despite correct codeRequest expired (>10 min)Request a new OTP

How Do You Add Error Handling and Logging?

Fastify's built-in logger (Pino) is already active. Add a centralized error handler to catch unhandled exceptions and format error responses consistently.

1. Add Custom Error Handler:

Update src/server.js to include setErrorHandler.

javascript
// src/server.js
require('dotenv').config();

const fastify = require('fastify')({
  logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp');

// --- Validate Environment Variables ---
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
  console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
  process.exit(1);
}

// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));

// --- Vonage Client Setup ---
const vonage = new Vonage({
  apiKey: process.env.VONAGE_API_KEY,
  apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);

// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' });

// --- Error Handling ---
fastify.setErrorHandler(function (error, request, reply) {
  // Log the error
  request.log.error(error);

  // Check if it's a validation error from Fastify schema check
  if (error.validation) {
    reply.status(400).send({
      success: false,
      message: 'Validation error',
      errors: error.validation, // Provides details on which fields failed
    });
    return;
  }

  // Check for specific error types you might throw or receive
  // Example: if (error instanceof MyCustomError) { ... }

  // Default fallback for other errors
  // Use the status code from the error if available (e.g., from Vonage errors handled in routes)
  const statusCode = error.statusCode || 500;
  reply.status(statusCode).send({
    success: false,
    message: error.message || 'An unexpected internal server error occurred.',
    // Avoid leaking stack traces in production environments
    ...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
  });
});


// --- Start Server ---
const start = async () => {
 // ... (start function)
  try {
    const port = process.env.PORT || 3000;
    await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
    fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Explanation:

  • fastify.setErrorHandler: Registers a function to handle errors that occur during request processing after the initial routing but before a reply is sent, or errors explicitly passed to reply.send(error).
  • It logs the full error using request.log.error(error).
  • It checks if the error is a Fastify validation error (error.validation) and returns a structured 400 response.
  • It provides a generic response for other unhandled errors, using the error's statusCode and message if available (falling back to 500).
  • Important: It conditionally includes the stack trace only if NODE_ENV is not 'production' to avoid leaking sensitive information.

Logging Best Practices:

  • Fastify automatically logs incoming requests and outgoing responses.
  • We added specific logging within the route handlers (request.log.info, request.log.error, request.log.warn) to provide context about the OTP flow.
  • During development (npm run dev), pino-pretty formats these JSON logs nicely. In production, pipe the raw JSON logs to a log management system (e.g., Datadog, ELK stack, Splunk) for analysis and alerting.

Key Log Queries for Monitoring:

  • Failed verifications: status:"failed" AND requestId:* – Identify suspicious patterns
  • Rate limit hits: message:"Rate limit exceeded" – Adjust limits if legitimate users are blocked
  • Vonage API errors: error_text:* AND status:!0 – Track API reliability
  • High-cost phone numbers: phoneNumber:"+9*" – Monitor international SMS costs

What Security Features Does Your OTP System Need?

Security is paramount for an authentication mechanism.

1. Rate Limiting:

Protect against brute-force attacks on both requesting and verifying OTPs. Learn more about implementing 2FA security best practices for SMS authentication.

  • Install Rate Limiter Plugin:

    bash
    npm install @fastify/rate-limit
  • Register and Configure: Add this to the "Plugin Registration" section in src/server.js.

    javascript
    // src/server.js
    // ... other imports
    require('dotenv').config(); // Ensure this is at the top
    
    const fastify = require('fastify')({
      logger: true
    });
    const { Vonage } = require('@vonage/server-sdk');
    const otpRoutes = require('./routes/otp');
    
    // --- Validate Environment Variables ---
    if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
      console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
      process.exit(1);
    }
    
    // --- Plugin Registration ---
    fastify.register(require('@fastify/rate-limit'), {
      max: 100, // Max requests per timeWindow per IP (adjust globally)
      timeWindow: '1 minute',
      // Optional: customize error response
      errorResponseBuilder: function (req, context) {
        return {
          success: false,
          message: `Rate limit exceeded, retry after ${context.after}`,
          code: 'RATE_LIMIT_EXCEEDED',
          retryAfter: context.ttl, // Time-to-live seconds for the ban
        }
      },
      // Optional: Key generator (e.g., use userId if authenticated)
      // keyGenerator: function (req) { return req.headers['x-real-ip'] || req.ip } // Default is IP
    });
    // Register Helmet here too (see step 4 below)
    // fastify.register(require('@fastify/helmet'));
    
    // --- Vonage Client Setup ---
    const vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET
    });
    fastify.decorate('vonage', vonage);
    
    // --- Route Registration ---
    fastify.register(otpRoutes, { prefix: '/api/v1' });
    
    // --- Error Handling ---
    // ... (setErrorHandler from Section 4)
    fastify.setErrorHandler(function (error, request, reply) {
        request.log.error(error);
        if (error.validation) {
            reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation });
            return;
        }
        const statusCode = error.statusCode || 500;
        reply.status(statusCode).send({
            success: false,
            message: error.message || 'An unexpected internal server error occurred.',
            ...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
        });
    });
    
    // --- Start Server ---
    const start = async () => {
        try {
            const port = process.env.PORT || 3000;
            await fastify.listen({ port: port, host: '0.0.0.0' });
            fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
        } catch (err) {
            fastify.log.error(err);
            process.exit(1);
        }
    };
    
    start();

Recommended Rate Limit Configuration:

EndpointMax RequestsTime WindowRationale
/request-otp3–51 hour per IPPrevents SMS flooding, reduces costs
/verify-otp5–1015 minutes per IPAllows for typos while preventing brute force
Global API1001 minute per IPGeneral protection against DoS
  • Apply Specific Limits (Optional but Recommended): You can override the global settings within specific route options in src/routes/otp.js. It's wise to have stricter limits on OTP requests than general API usage.

    javascript
    // src/routes/otp.js
    
    // ... schemas ...
    
    async function otpRoutes(fastify, options) {
    
      // --- Endpoint: Request OTP ---
      fastify.post('/request-otp', {
        schema: requestOtpSchema,
        config: { // Add rate limit config specific to this route
          rateLimit: {
            max: 5, // Allow only 5 requests per hour per key
            timeWindow: '1 hour',
             // WARNING: Using `phoneNumber` directly in `keyGenerator` can block legitimate users sharing IPs (e.g., behind NAT).
             // This approach is generally safer *only if* user authentication happens *before* the OTP request.
             // Otherwise, IP-based limiting (`req.ip`) is usually the safer default starting point. Combining IP and phone number is another option.
             // keyGenerator: function (req) { return req.body.phoneNumber || req.ip }
          }
        }
      }, async (request, reply) => {
        // ... route handler logic ...
      });
    
      // --- Endpoint: Verify OTP ---
       fastify.post('/verify-otp', {
        schema: verifyOtpSchema,
        config: { // Add rate limit config specific to this route
          rateLimit: {
            max: 10, // Allow slightly more verification attempts per key
            timeWindow: '15 minutes'
             // Keying by requestId might be useful here, combined with IP
             // keyGenerator: function (req) { return `${req.ip}-${req.body.requestId}` }
          }
        }
      }, async (request, reply) => {
         // ... route handler logic ...
       });
    }
    
    module.exports = otpRoutes;

Explanation:

  • @fastify/rate-limit: Provides robust rate limiting based on IP address by default.
  • max, timeWindow: Control how many requests are allowed within a specific timeframe.
  • errorResponseBuilder: Customizes the response when the limit is hit.
  • keyGenerator: Allows using factors other than IP (like phone number, requestId, or authenticated user ID) for more granular limiting. Use with caution, as poorly chosen keys can block legitimate users. IP-based is often the simplest starting point.
  • Route-specific config.rateLimit overrides the global settings for finer control.

2. Input Validation and Sanitization:

  • Fastify's schema validation (used in Section 3) handles basic input validation (checking types, required fields, lengths). This prevents many common injection-style attacks by ensuring data conforms to expectations before it's processed.
  • For phone numbers, Vonage performs its own validation, but using a library like google-libphonenumber on the backend before sending to Vonage provides earlier, more specific feedback to the user about formatting issues.

Phone Number Validation Example:

javascript
// Install: npm install google-libphonenumber

const { PhoneNumberUtil } = require('google-libphonenumber');
const phoneUtil = PhoneNumberUtil.getInstance();

function validatePhoneNumber(phoneNumber) {
  try {
    const parsed = phoneUtil.parse(phoneNumber);
    return phoneUtil.isValidNumber(parsed);
  } catch (error) {
    return false;
  }
}

// Use in route:
if (!validatePhoneNumber(phoneNumber)) {
  return reply.status(400).send({
    success: false,
    message: 'Invalid phone number format. Use E.164 format (e.g., +14155552671)'
  });
}

3. Secure Handling of Secrets:

  • Environment Variables: API keys are correctly stored in .env and loaded via dotenv.
  • .gitignore: The .env file is in .gitignore to prevent accidental commits.
  • Production: In production environments (like Docker, PaaS, servers), use the hosting provider's mechanism for managing environment variables securely (e.g., secrets management tools, platform environment variable settings). Do not deploy .env files directly to production servers.

4. Other Considerations:

  • HTTPS: Always run your API over HTTPS in production to encrypt data in transit. Use a reverse proxy like Nginx or Caddy, or platform-provided SSL termination (e.g., AWS ELB, Heroku).
  • Helmet: Consider using @fastify/helmet to set various security-related HTTP headers (like X-Frame-Options, Strict-Transport-Security).
    bash
    npm install @fastify/helmet
    Register it in src/server.js within the "Plugin Registration" section:
    javascript
    // src/server.js - Ensure this is registered in the Plugin Registration section
    fastify.register(require('@fastify/helmet'));

How Do You Test Your SMS OTP API?

Write automated tests to ensure reliability. Use tap, Fastify's default test runner.

1. Install tap as a Dev Dependency:

bash
npm install --save-dev tap

2. Update test script in package.json:

json
{
  "name": "fastify-vonage-otp",
  "version": "1.0.0",
  "description": "",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node src/server.js | pino-pretty",
    "test": "tap \"test/**/*.test.js\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@fastify/helmet": "^...",
    "@fastify/rate-limit": "^...",
    "@vonage/server-sdk": "^...",
    "dotenv": "^...",
    "fastify": "^..."
  },
  "devDependencies": {
    "pino-pretty": "^...",
    "tap": "^..."
  }
}

(Note: Added @fastify/helmet, @fastify/rate-limit to dependencies and tap, pino-pretty to devDependencies based on previous steps. Ensure versions match your installation.)

3. Create Test File Structure:

bash
mkdir test
mkdir test/routes
touch test/routes/otp.test.js

4. Write Comprehensive Tests:

Here's an example testing multiple scenarios for both endpoints, mocking the Vonage API call.

javascript
// test/routes/otp.test.js
'use strict'

const { test } = require('tap')

// Placeholder for building the app - replace with your actual helper
async function build(t, mockVonage) {
  const fastify = require('fastify')();

  // Mock the decorator if mockVonage is provided
  if (mockVonage) {
    fastify.decorate('vonage', mockVonage);
  } else {
    // Provide a default mock if none is passed, to avoid errors
    fastify.decorate('vonage', { verify: { start: async () => {}, check: async () => {} } });
  }

  // Register routes, error handlers etc. as in server.js
  const otpRoutes = require('../../src/routes/otp');
  fastify.register(otpRoutes, { prefix: '/api/v1' });

  // Add setErrorHandler from server.js
  fastify.setErrorHandler(function (error, request, reply) {
    request.log.error(error);
    if (error.validation) {
      reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation });
      return;
    }
    const statusCode = error.statusCode || 500;
    reply.status(statusCode).send({
      success: false,
      message: error.message || 'An unexpected internal server error occurred.',
    });
  });

  await fastify.ready();
  t.teardown(() => fastify.close());
  return fastify;
}


test('POST /api/v1/request-otp - success', async (t) => {
  const mockVonage = {
    verify: {
      start: async (options) => {
        t.equal(options.number, '+12345678900', 'should pass correct phone number');
        t.ok(options.brand, 'should pass a brand name');
        return { status: '0', request_id: 'mock-request-id-123' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/request-otp',
    payload: {
      phoneNumber: '+12345678900'
    }
  });

  t.equal(res.statusCode, 200, 'returns a status code of 200');
  const payload = JSON.parse(res.payload);
  t.ok(payload.success, 'success field should be true');
  t.equal(payload.requestId, 'mock-request-id-123', 'returns the correct request ID');
});

test('POST /api/v1/request-otp - validation failure (missing phone)', async (t) => {
 const app = await build(t);

 const res = await app.inject({
   method: 'POST',
   url: '/api/v1/request-otp',
   payload: { /* Missing phoneNumber */ }
 });

 t.equal(res.statusCode, 400, 'returns a status code of 400 for validation error');
 const payload = JSON.parse(res.payload);
 t.notOk(payload.success, 'success field should be false');
 t.ok(payload.message.toLowerCase().includes('validation'), 'error message indicates validation failure');
});

test('POST /api/v1/request-otp - Vonage error', async (t) => {
  const mockVonage = {
    verify: {
      start: async () => {
        return { status: '3', error_text: 'Invalid credentials' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/request-otp',
    payload: { phoneNumber: '+12345678900' }
  });

  t.equal(res.statusCode, 500, 'returns 500 for Vonage error');
  const payload = JSON.parse(res.payload);
  t.notOk(payload.success, 'success should be false');
  t.ok(payload.message.includes('Invalid credentials'), 'includes Vonage error message');
});

test('POST /api/v1/verify-otp - success', async (t) => {
  const mockVonage = {
    verify: {
      check: async (requestId, code) => {
        t.equal(requestId, 'test-request-id', 'should pass correct request ID');
        t.equal(code, '1234', 'should pass correct code');
        return { status: '0' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/verify-otp',
    payload: {
      requestId: 'test-request-id',
      code: '1234'
    }
  });

  t.equal(res.statusCode, 200, 'returns 200 for successful verification');
  const payload = JSON.parse(res.payload);
  t.ok(payload.success, 'success should be true');
  t.ok(payload.message.includes('verified'), 'message confirms verification');
});

test('POST /api/v1/verify-otp - incorrect code', async (t) => {
  const mockVonage = {
    verify: {
      check: async () => {
        return { status: '6', error_text: 'The code provided does not match' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/verify-otp',
    payload: {
      requestId: 'test-request-id',
      code: '9999'
    }
  });

  t.equal(res.statusCode, 400, 'returns 400 for incorrect code');
  const payload = JSON.parse(res.payload);
  t.notOk(payload.success, 'success should be false');
  t.equal(payload.errorCode, '6', 'includes correct error code');
});

test('POST /api/v1/verify-otp - expired request', async (t) => {
  const mockVonage = {
    verify: {
      check: async () => {
        return { status: '101', error_text: 'No response found' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/verify-otp',
    payload: {
      requestId: 'expired-request-id',
      code: '1234'
    }
  });

  t.equal(res.statusCode, 404, 'returns 404 for expired request');
  const payload = JSON.parse(res.payload);
  t.notOk(payload.success, 'success should be false');
  t.ok(payload.message.toLowerCase().includes('expired'), 'message indicates expiration');
});

test('POST /api/v1/verify-otp - already verified', async (t) => {
  const mockVonage = {
    verify: {
      check: async () => {
        return { status: '16', error_text: 'Request already verified' };
      }
    }
  };

  const app = await build(t, mockVonage);

  const res = await app.inject({
    method: 'POST',
    url: '/api/v1/verify-otp',
    payload: {
      requestId: 'already-verified-id',
      code: '1234'
    }
  });

  t.equal(res.statusCode, 409, 'returns 409 Conflict for already verified');
  const payload = JSON.parse(res.payload);
  t.notOk(payload.success, 'success should be false');
  t.ok(payload.message.toLowerCase().includes('already'), 'message indicates already verified');
});

5. Create Test Helper (Conceptual):

A file like test/helper.js would typically contain a function to build and configure the Fastify app instance for testing, allowing injection of mocks and handling teardown. Creating a full helper is beyond the scope of this guide, but the test file structure assumes its existence.

Run tests using npm test. Add comprehensive tests covering success paths, error paths (Vonage errors, validation errors), and security features like rate limiting.

Integration Testing with Vonage:

For integration tests with real Vonage test numbers:

  1. Create a separate test environment with dedicated Vonage credentials
  2. Use Vonage test numbers (start with +15005550) that don't send real SMS
  3. Test with +15005550006 – Vonage treats this as valid but doesn't deliver SMS
  4. Monitor your Vonage dashboard for test request logs
  5. Keep integration tests separate from unit tests (test/integration/) to avoid incurring costs during development

What Should You Consider When Deploying to Production?

Follow these critical best practices when deploying your SMS OTP service to production:

Environment Variables and Secrets Management:

  • Never deploy .env files to production servers. Use your hosting platform's environment variable management (AWS Systems Manager, Heroku Config Vars, Google Secret Manager, Azure Key Vault).
  • Rotate API credentials periodically and immediately if compromised.
  • Use different Vonage API credentials for development, staging, and production environments.

HTTPS and Transport Security:

  • Always use HTTPS in production to encrypt data in transit. Use a reverse proxy (Nginx, Caddy) or platform-provided SSL termination (AWS ALB, Cloudflare).
  • The @fastify/helmet plugin (covered in Section 5) sets important security headers including Strict-Transport-Security (HSTS).

Logging and Monitoring:

In production, pipe Fastify's JSON logs to a log aggregation service (Datadog, ELK Stack, Splunk, Papertrail) for analysis and alerting.

Key Metrics to Monitor:

MetricThresholdAction
OTP request rate>1000/hourInvestigate for abuse, check rate limits
Verification failure rate>30%Check for UX issues or attack patterns
API response time (p95)>500msScale infrastructure or optimize code
Vonage API errors>5%Check API status, validate credentials
Rate limit hits>100/hourReview if legitimate users are blocked

Alert Patterns:

  • Spike in failed verifications: May indicate brute-force attack
  • Increased OTP requests from single IP: Potential DoS or abuse
  • Unusual geographic patterns: Monitor requests from unexpected countries
  • Cost threshold exceeded: Set billing alerts in Vonage dashboard

Rate Limiting Tuning:

  • Start with conservative rate limits and adjust based on legitimate usage patterns.
  • Consider implementing different rate limit tiers for authenticated vs. anonymous users.
  • Monitor rate limit hits to identify potential attacks or legitimate users being blocked.

Testing Before Production:

  • Test the complete OTP flow with real phone numbers in a staging environment.
  • Verify that error handling works correctly for all Vonage API error statuses.
  • Load test your API endpoints to understand performance limits and identify bottlenecks.
  • Test rate limiting behavior under realistic traffic patterns.

Disaster Recovery and Failover:

  • Database backups: If storing verification logs, implement automated backups with point-in-time recovery
  • Multi-region deployment: Deploy to multiple regions for high availability
  • Fallback workflows: Implement alternative verification methods (email, voice) if SMS delivery fails
  • Circuit breakers: Use circuit breaker patterns to handle Vonage API outages gracefully
  • Health checks: Implement /health endpoints that verify Vonage API connectivity

Compliance and Privacy:

Ensure your OTP implementation complies with telecommunications regulations:

RegionRegulationRequirements
United StatesTCPAObtain prior express written consent before sending SMS
European UnionGDPRImplement data retention limits, provide opt-out mechanisms
CanadaCASLGet explicit consent, include identification in messages
AustraliaSpam ActMaintain unsubscribe functionality, clear sender identification

Implementation Checklist:

  • Obtain explicit user consent before sending SMS
  • Implement data retention policies (recommend 30–90 days for logs)
  • Provide clear privacy policies documenting phone number usage
  • Honor opt-out requests within 24 hours
  • Include sender identification in all SMS messages
  • Store only hashed phone numbers in long-term storage
  • Implement right-to-deletion for user data

Cost Management:

  • Set up billing alerts in your Vonage dashboard to monitor unexpected usage spikes.
  • Implement request validation to prevent sending OTPs to invalid or test phone numbers.
  • Consider implementing CAPTCHA before OTP requests to prevent automated abuse.

Sources: Fastify Production Best Practices, OWASP Authentication Cheat Sheet, Vonage API Security Best Practices, accessed October 2025.

Frequently Asked Questions About SMS OTP 2FA

How do I implement OTP verification in Node.js?

Implement OTP verification in Node.js by using the Vonage Verify API with Fastify. Install the @vonage/server-sdk, create routes for sending OTP codes via /request-otp, and verify user-submitted tokens via /verify-otp. Use vonage.verify.start() to generate and send OTP codes, then vonage.verify.check() to validate user input against the codes stored by Vonage.

What is the Vonage Verify API?

The Vonage Verify API handles OTP generation, SMS delivery, and token verification for two-factor authentication. It automatically generates cryptographically secure random codes (4–6 digits), sends them via SMS, voice, email, or WhatsApp, and validates user-submitted tokens within configurable timeout windows (5–10 minutes by default).

Why use Fastify for SMS authentication?

Use Fastify for SMS authentication because it provides 6x faster performance than Express with built-in schema validation via JSON Schema and a robust plugin ecosystem. Fastify v5's native async/await support and low overhead make it ideal for high-throughput authentication services handling thousands of concurrent OTP requests.

What Node.js version is required for this tutorial?

This tutorial requires Node.js v20 or later. Fastify v5 specifically requires Node.js v20+ for native async hooks and performance optimizations. The @vonage/server-sdk v3.24+ works with Node.js v14+ but performs best on v20+.

How long do Vonage OTP codes remain valid?

Vonage OTP codes remain valid for 5–10 minutes by default, depending on the workflow configuration. You can configure expiration by setting the workflow_id parameter when calling verify.start(). Expired codes cannot be verified and require users to request a new OTP.

How do I secure my Vonage API credentials?

Store Vonage API keys in environment variables via .env files (never commit to version control). Use live keys (starting with live_) for production and test keys for development. Consider IP whitelisting in the Vonage dashboard if your server uses static IPs, and monitor usage for anomalies via the dashboard.

What is E.164 phone number format?

E.164 is the international phone number format used by telecommunications systems. It starts with a + followed by the country code and national number with no spaces or special characters (e.g., +14155552671 for US, +442071234567 for UK). The Vonage API requires E.164 format and may reject improperly formatted numbers.

How do I implement rate limiting for OTP endpoints?

Implement rate limiting using @fastify/rate-limit plugin. Configure stricter limits for OTP request endpoints (3–5 requests per hour per IP) compared to verification endpoints (5–10 per 15 minutes). This prevents SMS flooding, reduces costs, and protects against brute-force attacks while allowing legitimate user typos.

Can I use Vonage OTP for login authentication?

Yes, Vonage OTP is ideal for login authentication as a second factor beyond passwords. After users enter their username and password, send an OTP to their registered phone number via /request-otp, then verify the code they enter via /verify-otp. This significantly enhances account security against credential theft. For more authentication patterns, see our guide on implementing two-factor authentication in Node.js.

How much does each Vonage OTP request cost?

Each Vonage OTP request costs approximately $0.06–$0.10 USD depending on the destination country. Implement proper rate limiting, phone number validation, and consider adding CAPTCHA before OTP requests to prevent malicious actors from generating excessive verification requests that increase costs.

Conclusion

You've built a production-ready SMS OTP verification system using Fastify and the Vonage Verify API. This implementation includes:

  • Two secure API endpoints for requesting and verifying OTPs
  • Comprehensive error handling and logging
  • Rate limiting protection against abuse
  • Input validation and security headers
  • Automated testing framework
  • Production deployment guidance

Next Steps:

Integrate this API into your application with these specific patterns:

Frontend Integration:

javascript
// Request OTP during user registration
async function requestOTP(phoneNumber) {
  const response = await fetch('/api/v1/request-otp', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ phoneNumber })
  });
  const data = await response.json();
  return data.requestId; // Store this for verification
}

// Verify OTP when user submits code
async function verifyOTP(requestId, code) {
  const response = await fetch('/api/v1/verify-otp', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ requestId, code })
  });
  return await response.json();
}

User Account Association:

  • Store verified phone numbers in your user database after successful verification
  • Link the requestId to user sessions during registration/login flows
  • Implement phone number change workflows that require re-verification

Multi-Channel Support:

  • Explore Vonage Verify v2's additional channels (Email, WhatsApp, Silent Authentication)
  • Implement fallback to voice calls if SMS delivery fails
  • Consider WhatsApp for markets with high WhatsApp penetration

Scaling Strategies:

  • Implement Redis for distributed rate limiting across multiple servers
  • Use message queues (RabbitMQ, AWS SQS) for asynchronous OTP processing
  • Cache Vonage API responses to reduce duplicate requests
  • Consider horizontal scaling with load balancers for high traffic

Additional Resources:

This guide provides a solid foundation for implementing secure SMS-based two-factor authentication in your Node.js applications.

Frequently Asked Questions

How to send SMS OTP with Node.js and Fastify?

Use the Vonage Verify API with Fastify and Node.js. Create a Fastify API endpoint that takes the user's phone number and makes a request to the Vonage Verify API to initiate the OTP process. This sends a unique OTP to the user's phone number via SMS. The response includes a request ID for verification.

What is the Vonage Verify API used for in 2FA?

The Vonage Verify API handles generating, sending, and verifying one-time passwords (OTPs) for two-factor authentication (2FA). It simplifies the process by providing pre-built logic and infrastructure for OTP delivery via SMS or voice calls. This offloads the complexity of building and maintaining an OTP system yourself.

Why use Fastify for a Node.js OTP API?

Fastify is a high-performance Node.js web framework known for its speed and extensibility. Its built-in features like schema validation, logging, and a plugin-friendly architecture make it a strong choice for building robust and maintainable APIs. The low overhead also adds to improved efficiency.

When to implement SMS OTP/2FA in an app?

Implement SMS OTP/2FA for actions requiring enhanced security, such as user registration, login, password resets, and sensitive transactions. This provides a strong second layer of authentication. The Vonage Verify API makes implementing 2FA easier.

Can I customize the OTP length with Vonage?

While the default is 4 digits, you can configure Vonage to send 6-digit OTPs or define custom workflows. Uncomment and adjust the `code_length` or `workflow_id` options in the `vonage.verify.start()` call in the Node.js code for customizations.

What are the prerequisites for Vonage SMS OTP?

You'll need Node.js v16 or later with npm/yarn, a Vonage API account with an API key and secret, and a basic understanding of APIs and asynchronous JavaScript. Installing the Vonage CLI is optional but helpful. These steps allow you to get up and running quickly.

How to verify OTP received via Vonage?

Your Fastify API should have a separate endpoint that receives the `requestId` (from the OTP request) and the `code` entered by the user. Call `vonage.verify.check()` to verify the OTP against Vonage's records and return a verification success or failure status to the client app based on the response.

What is the role of dotenv in Node.js OTP?

The `dotenv` module loads environment variables from a `.env` file into `process.env`. This is crucial for keeping sensitive data like your Vonage API credentials secure. The file is put in the .gitignore file and not committed to the repository.

How does Fastify handle schema validation?

Fastify uses JSON Schema to validate request bodies. Define the expected request structure in route options. Fastify automatically checks incoming requests and returns a 400 Bad Request error if they don't match, enhancing security and providing clear error messaging to clients.

What does status '0' mean in Vonage Verify API?

A status of '0' in the Vonage Verify API response means the request was successfully initiated. This typically appears after calling `vonage.verify.start()` indicating Vonage has accepted the request to send the OTP. It does not guarantee delivery.

Why add a prefix (/api/v1) to API routes?

Adding prefixes like `/api/v1` to your API routes is a best practice for versioning. This allows you to introduce future versions (e.g., `/api/v2`) with breaking changes without impacting existing integrations that rely on the older version.

How to handle Vonage Verify API errors?

Check the `status` code in the Vonage Verify API response. Non-zero status codes indicate an error and should be handled appropriately. The `error_text` provides further details. Use a centralized error handler in Fastify and custom messages for specific error codes.

How to implement rate limiting for OTP requests?

Use the `@fastify/rate-limit` plugin to protect your OTP endpoints from abuse. Configure global limits and/or per-route limits to control how often clients can request or verify OTPs. Use the `keyGenerator` option to customize which factor (IP, phone number, requestId) the rate limiting should apply to.

What are security best practices for Node.js OTP APIs?

Key security measures include: always using HTTPS, rate limiting OTP requests, validating and sanitizing input, storing API keys securely (environment variables, secrets management), using Helmet for security headers, and writing thorough automated tests to ensure the reliability of your OTP system.