code examples

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

How to Implement SMS OTP Authentication in Next.js with Vonage Verify API V2 (2025 Guide)

Learn how to add SMS OTP and two-factor authentication in Next.js using Vonage Verify API V2 with Node.js/Express. Complete code examples, security best practices, and production deployment guide.

Build OTP/2FA with Vonage Verify API V2, Node.js & Next.js

Learn how to implement secure SMS OTP authentication and two-factor authentication (2FA) in your Next.js application using Vonage's Verify API V2 with a Node.js/Express backend. This comprehensive tutorial covers building a production-ready OTP authentication system with phone verification, security best practices, rate limiting, error handling, and deployment strategies – everything you need to add SMS verification and two-factor authentication to protect user accounts.

Project Overview and Goals

What You'll Build:

Create a simple application demonstrating a complete SMS OTP authentication flow:

  1. Enter your phone number on a Next.js frontend.
  2. The frontend sends the number to a Node.js/Express backend API.
  3. The backend uses the Vonage Verify V2 API to initiate an OTP request (sending a code via SMS or voice call to your phone).
  4. Receive the code and enter it into the Next.js frontend.
  5. The frontend sends the code and a unique request identifier back to the backend API. (Note: For simplicity, this guide sends the request ID to the client. Section 6 discusses a more secure server-side session approach.)
  6. The backend uses the Vonage Verify V2 API to check the code's validity.
  7. The frontend displays a success or failure message.

Problem Solved:

Add a critical security layer beyond traditional passwords. By requiring users to possess their phone to receive and enter a time-sensitive code, you significantly reduce the risk of unauthorized account access – even if passwords are compromised through phishing, credential stuffing, or data breaches.

Technologies Used:

  • Frontend: Next.js (React Framework) – Modern features, performance optimizations, and excellent developer experience for building user interfaces. Uses the App Router.
  • Backend: Node.js with Express – Robust and widely-adopted combination for building efficient and scalable APIs. This Express server runs separately from the Next.js application.
  • Authentication Service: Vonage Verify API V2 – Managed, reliable, and global service for sending and verifying OTPs across multiple channels (SMS, Voice), simplifying the complex infrastructure required for phone verification and 2FA.
  • Vonage Node SDK (@vonage/server-sdk): Simplifies interaction with the Vonage API from your Node.js backend.
  • Environment Management: dotenv – Securely manage API keys and configuration variables.
  • API Communication: Standard fetch API in Next.js, express.json() and cors middleware in Express.

System Architecture:

text
+-----------------+      +-------------------+      +-------------------+      +-----------------+
|  User Browser   | ---> | Next.js Frontend  | ---> | Node.js/Express | ---> |  Vonage Verify  |
| (Input Phone/  |      | (React Components,|      |   Backend API   |      |      API V2     |
|  Input Code)    |      |   API Calls)      |      | (/request, /check)|      | (Send/Check OTP)|
+-----------------+      +--------^----------+      +--------^----------+      +-----------------+
       |                        |                        |
       | Success/Error          | Success/Error + ReqID* | Success/Error
       +------------------------+------------------------+
                                *Note: Sending ReqID to client is less secure. See Sec 6.

Prerequisites:

  • Node.js (v20 or later recommended; v18 reached EOL March 27, 2025) and npm/yarn installed.
  • A Vonage API account. You can sign up for free credit.
  • Basic understanding of JavaScript, React, Next.js, and Node.js/Express.
  • A text editor (like VS Code) and a terminal.

Expected Outcome:

By the end of this guide, you will have a functional application with SMS OTP authentication. Users can request an OTP, receive it on their phone, enter it, and have it verified. You will also understand the principles of integrating Vonage Verify V2 for phone verification, handling potential errors, and implementing security best practices.


How to Set Up Your Next.js and Node.js Project

Create separate directories for the frontend and backend for clarity.

1.1. Create Project Structure:

Open your terminal and create a main project directory, then navigate into it.

bash
mkdir vonage-otp-app
cd vonage-otp-app

Create directories for the frontend and backend:

bash
mkdir frontend backend

1.2. Backend Setup (Node.js/Express):

Navigate into the backend directory:

bash
cd backend

Initialize the Node.js project:

bash
npm init -y

This creates a package.json file.

Install necessary dependencies:

  • express: The web framework.
  • @vonage/server-sdk: The official Vonage Node.js SDK (V3+ for Verify V2).
  • dotenv: To load environment variables from a .env file.
  • cors: To enable Cross-Origin Resource Sharing (needed because frontend and backend run on different ports during development).
bash
npm install express @vonage/server-sdk dotenv cors

Create the main server file:

bash
touch server.js

Create a .env file to store sensitive credentials:

bash
touch .env

Open the .env file and add your Vonage API credentials and a brand name. IMPORTANT: Replace the placeholder values (YOUR_API_KEY, YOUR_API_SECRET) with your actual credentials found on the Vonage Dashboard.

dotenv
# backend/.env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=MyAppName # Brand name shown in SMS message (max 18 chars, alphanumeric recommended)
PORT=5001 # Port for the backend server

Explanation of .env Variables:

  • VONAGE_API_KEY: Your unique identifier for accessing the Vonage API. Found on your Vonage Dashboard.
  • VONAGE_API_SECRET: Your secret key for authenticating API requests. Found on your Vonage Dashboard. Treat this like a password.
  • VONAGE_BRAND_NAME: The name displayed as the sender in the OTP message. Maximum 18 characters. Helps users identify the origin of the code. Must match regex pattern ^[^\/{}:$]*$ for security reasons.
  • PORT: The network port your Express server will listen on.

IMPORTANT: Secure Your Credentials

Create a .gitignore file in the backend directory to prevent accidentally committing your secrets:

bash
touch .gitignore

Add the following lines to backend/.gitignore:

text
# backend/.gitignore
node_modules/
.env

1.3. Frontend Setup (Next.js):

Navigate back to the root vonage-otp-app directory and then into the frontend directory:

bash
cd ../frontend

Create a new Next.js application using the App Router:

bash
npx create-next-app@latest . --typescript=no --eslint=yes --tailwind=no --src-dir=no --app=yes --import-alias="@/*"

(Answer prompts as needed. Choose no TypeScript/Tailwind/src dir for simplicity here, but feel free to adjust).

This command scaffolds a new Next.js project in the current directory (.).

The frontend doesn't require additional dependencies for this core functionality.

Add Frontend .gitignore:

Ensure your frontend/.gitignore file (usually created by create-next-app) includes lines to ignore local environment files and build artifacts:

text
# frontend/.gitignore (ensure these lines exist or add them)
node_modules/
.next/
out/
.env*.local

How to Build the Vonage Verify API Backend with Node.js

Build the Express API endpoints that will interact with the Vonage Verify V2 API for SMS OTP authentication.

2.1. Basic Express Server Setup:

Open the backend/server.js file and add the following initial setup:

javascript
// backend/server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const cors = require('cors');
const { Vonage } = require('@vonage/server-sdk'); // Vonage SDK V3+

const app = express();
const port = process.env.PORT || 5001;

// --- Vonage Client Initialization ---
// Ensure API Key and Secret are loaded
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
  console.error('Error: VONAGE_API_KEY and VONAGE_API_SECRET must be set in .env file');
  process.exit(1); // Exit if credentials are missing
}

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

// --- Middleware ---
app.use(cors()); // Enable CORS for requests from the frontend (adjust in production)
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies

// --- API Routes (To be added below) ---
app.get('/health', (req, res) => {
    res.status(200).json({ status: 'OK', message: 'Backend is running' });
}); // Basic health check endpoint

// --- Start Server ---
app.listen(port, () => {
  console.log(`Backend server listening at http://localhost:${port}`);
});

Explanation:

  • require('dotenv').config(): Loads variables from the .env file into process.env. Crucial for accessing credentials.
  • new Vonage(...): Initializes the Vonage SDK client with your credentials.
  • app.use(cors()): Allows requests from your Next.js frontend (running on a different port, e.g., 3000) to reach the backend (running on 5001). For production, configure CORS more restrictively.
  • app.use(express.json()): Enables the server to understand incoming request bodies formatted as JSON.
  • /health route: A simple endpoint to check if the server is running.

2.2. Implementing the /api/request-verification Endpoint:

This endpoint receives a phone number from the frontend and triggers the Vonage OTP request using Verify V2.

Add the following route before the app.listen call in backend/server.js:

javascript
// backend/server.js
// ... (previous setup code) ...

// --- API Routes ---

// Endpoint to request a verification code (Verify V2)
app.post('/api/request-verification', async (req, res) => {
  const { phoneNumber } = req.body;

  // Basic validation (more robust validation recommended in production)
  if (!phoneNumber) {
    return res.status(400).json({ error: 'Phone number is required.' });
  }

  console.log(`Requesting verification for: ${phoneNumber}`);

  try {
    const result = await vonage.verify.start({
      number: phoneNumber,
      brand: process.env.VONAGE_BRAND_NAME || 'MyApp',
      code_length: 6 // Request a 6-digit code (default is 4 for V1, 6 is common)
      // workflow_id: Can specify SMS/Voice/Silent Auth etc. if needed
    });

    console.log('Verification request sent successfully. Request ID:', result.request_id);

    // ** SECURITY WARNING **
    // Sending the request_id back to the client is simpler for this demo,
    // but LESS SECURE. The client could potentially misuse it.
    // A better approach (see Section 6) is to store the request_id
    // in a server-side session associated with the user.
    return res.status(200).json({ requestId: result.request_id });

  } catch (error) {
    console.error('Vonage API Error:', error);
    // Try to extract a meaningful error message
    let errorMessage = 'Failed to initiate verification.';
    let statusCode = 500;

    if (error.response && error.response.data) {
        // Handle errors structured according to Vonage API V2 spec
        errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
        if (error.response.data.type && error.response.data.type.includes('validation')) {
            statusCode = 400; // Bad request (e.g., invalid number)
        }
        if (error.response.status === 429) {
             statusCode = 429; // Throttling
             errorMessage = 'Too many requests. Please try again later.';
        } else {
             statusCode = error.response.status || 500;
        }
        console.error('Vonage Error Details:', error.response.data);
    } else if (error.message) {
         errorMessage = error.message;
    }

    return res.status(statusCode).json({ error: errorMessage });
  }
});

// ... (health check route and app.listen) ...

Explanation:

  1. The route listens for POST requests at /api/request-verification. It's now async.
  2. It extracts the phoneNumber from the JSON request body (req.body).
  3. Basic validation checks if the phone number exists.
  4. vonage.verify.start() (Verify V2 method) is called using await:
    • number: The user's phone number (should be in E.164 format ideally, e.g., +14155552671).
    • brand: The sender name from your .env file.
    • code_length: Set to 6 for a 6-digit OTP.
  5. Error Handling: A try...catch block handles potential errors during the API call. Vonage SDK V3+ throws errors on failure. We inspect the error object (often error.response.data for API errors) to provide a more specific message and status code.
  6. requestId Handling (Security Note): Upon success, the request_id is returned. The code includes a prominent warning explaining that sending this to the client is less secure and refers to Section 6 for the recommended server-side session approach.

2.3. Implementing the /api/check-verification Endpoint:

This endpoint receives the requestId (obtained from the previous step) and the OTP code entered by the user, then checks their validity with Vonage Verify V2.

Add the following route before the app.listen call in backend/server.js:

javascript
// backend/server.js
// ... (previous setup code and /api/request-verification route) ...

// Endpoint to check the verification code (Verify V2)
app.post('/api/check-verification', async (req, res) => {
  const { requestId, code } = req.body;

  // Basic validation
  if (!requestId || !code) {
    return res.status(400).json({ error: 'Request ID and code are required.' });
  }
  if (code.length !== 6 || !/^\d+$/.test(code)) { // Basic check for 6 digits
      return res.status(400).json({ error: 'Invalid code format. Must be 6 digits.' });
  }

  console.log(`Checking verification for Request ID: ${requestId} with Code: ${code}`);

  try {
    const result = await vonage.verify.check({
      request_id: requestId,
      code: code
    });

    // Verify V2 'check' returns status 200 OK with {request_id, status: "completed"}
    // when the code is correct. Per Vonage Verify V2 API spec (as of 2025).
    console.log('Verification successful for Request ID:', requestId);

    // In a real app, you would now:
    // 1. Mark the user's 2FA as verified for this session (if using sessions).
    // 2. Potentially link this device/verification if needed.
    // 3. Grant access to the protected resource/complete login.
    return res.status(200).json({ message: 'Verification successful.' });

  } catch (error) {
    console.error('Vonage API Check Error:', error);
    let errorMessage = 'Failed to check verification code.';
    let statusCode = 500;

    if (error.response && error.response.data) {
        // Handle specific V2 check errors
        errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
        statusCode = error.response.status || 500; // Use status from Vonage response

        if (statusCode === 400) { // Often incorrect code
            errorMessage = 'The code you entered was incorrect or has expired. Please try again.';
        } else if (statusCode === 410) { // Gone - request expired or already used
             errorMessage = 'Verification request not found, has expired, or already used. Please request a new code.';
        } else if (statusCode === 409) { // Conflict - concurrent verification (Vonage-specific)
             errorMessage = 'Concurrent verifications to the same number are not allowed. Please wait for the current verification to complete.';
        } else if (statusCode === 429) { // Throttling on check attempts
            errorMessage = 'Too many check attempts. Please request a new code.';
        }
        console.error('Vonage Check Error Details:', error.response.data);
    } else if (error.message) {
        errorMessage = error.message;
    }

    return res.status(statusCode).json({ error: errorMessage });
  }
});

// ... (health check route and app.listen) ...

Explanation:

  1. The route listens for POST requests at /api/check-verification. It's async.
  2. It extracts the requestId and the code entered by the user.
  3. Basic validation checks if both fields exist and if the code looks like a 6-digit number.
  4. vonage.verify.check() (Verify V2 method) is called using await:
    • request_id: The ID of the verification attempt.
    • code: The OTP code entered by the user.
  5. Error Handling & Status Check:
    • Verify V2's check method succeeds by returning a 200 OK status without throwing an error. If the await completes without error, the code is valid.
    • If the code is invalid, expired, or the request ID doesn't exist, the SDK throws an error. The catch block inspects error.response to determine the cause (e.g., status codes 400, 410, 429) and provides appropriate user feedback.
  6. A success (200 OK) or error JSON response is sent to the frontend.

How to Build the Next.js Frontend for OTP Verification

Now, let's create the user interface in Next.js to interact with our backend API for SMS OTP authentication.

3.1. Create the Page Component:

Replace the contents of frontend/app/page.js with the following code:

jsx
// frontend/app/page.js
'use client'; // Required for components with hooks like useState, useEffect

import React, { useState } from 'react';

export default function HomePage() {
  const [phoneNumber, setPhoneNumber] = useState('');
  const [code, setCode] = useState('');
  const [requestId, setRequestId] = useState('');
  const [verificationStep, setVerificationStep] = useState('request'); // 'request' or 'check'
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState(''); // For success/error messages
  const [messageType, setMessageType] = useState(''); // 'success' or 'error'

  // --- Configuration ---
  // Use environment variables in a real app for the backend URL
  const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:5001';

  // --- Handlers ---
  const handleRequestCode = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    setMessage('');
    setMessageType('');

    try {
      const response = await fetch(`${BACKEND_URL}/api/request-verification`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber }),
      });

      const data = await response.json();

      if (!response.ok) {
        // Handle errors from the backend API
        throw new Error(data.error || `HTTP error! status: ${response.status}`);
      }

      // Success! Store request ID and move to the next step
      // Note: Storing sensitive IDs client-side has security implications. See backend notes.
      setRequestId(data.requestId);
      setVerificationStep('check');
      setMessage('Verification code sent! Please check your phone.');
      setMessageType('success');

    } catch (error) {
      console.error('Request Code Error:', error);
      setMessage(error.message || 'Failed to send verification code. Please try again.');
      setMessageType('error');
    } finally {
      setIsLoading(false);
    }
  };

  const handleCheckCode = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    setMessage('');
    setMessageType('');

    try {
      const response = await fetch(`${BACKEND_URL}/api/check-verification`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ requestId, code }), // Send requestId stored in state
      });

      const data = await response.json();

      if (!response.ok) {
        // Handle errors from the backend API
        throw new Error(data.error || `HTTP error! status: ${response.status}`);
      }

      // Verification successful!
      setMessage('Verification successful! Welcome!');
      setMessageType('success');
      // In a real app, redirect or grant access here
      // Reset state for potential future use (optional)
      // setVerificationStep('request');
      // setPhoneNumber('');
      // setCode('');
      // setRequestId('');

    } catch (error) {
      console.error('Check Code Error:', error);
      setMessage(error.message || 'Verification failed. Please check the code or request a new one.');
      setMessageType('error');
      // Optionally allow user to go back or retry
      // if (error.message.includes('expired')) {
      //   setVerificationStep('request'); // Go back if expired
      // }
    } finally {
      setIsLoading(false);
    }
  };

  // --- Render Logic ---
  return (
    <div style={styles.container}>
      <h1>Vonage OTP/2FA Demo</h1>

      {message && (
        <p style={messageType === 'error' ? styles.errorMessage : styles.successMessage}>
          {message}
        </p>
      )}

      {verificationStep === 'request' && (
        <form onSubmit={handleRequestCode} style={styles.form}>
          <h2>Step 1: Enter Phone Number</h2>
          <p>Enter your phone number (e.g., +14155552671) to receive a verification code.</p>
          <input
            type="tel" // Use standard tel type
            value={phoneNumber}
            onChange={(e) => setPhoneNumber(e.target.value)}
            placeholder="+12345678900"
            required
            style={styles.input}
            disabled={isLoading}
          />
          <button type="submit" style={styles.button} disabled={isLoading}>
            {isLoading ? 'Sending...' : 'Send Code'}
          </button>
        </form>
      )}

      {verificationStep === 'check' && (
        <form onSubmit={handleCheckCode} style={styles.form}>
          <h2>Step 2: Enter Verification Code</h2>
          <p>Enter the 6-digit code sent to {phoneNumber}.</p>
           <input
            type="text" // Use "text" with pattern for better mobile input suggestions
            inputMode="numeric" // Hint for numeric keyboard
            pattern="\d{6}" // Basic pattern for 6 digits
            maxLength="6"
            value={code}
            onChange={(e) => setCode(e.target.value)}
            placeholder="123456"
            required
            style={styles.input}
            disabled={isLoading}
          />
          <button type="submit" style={styles.button} disabled={isLoading}>
            {isLoading ? 'Verifying...' : 'Verify Code'}
          </button>
           <button
             type="button"
             onClick={() => {
                setVerificationStep('request');
                setMessage('');
                setCode(''); // Clear code if going back
             }}
             style={{...styles.button, ...styles.secondaryButton}}
             disabled={isLoading}
            >
             Change Phone Number
           </button>
        </form>
      )}
    </div>
  );
}

// Basic inline styles for demonstration
const styles = {
  container: {
    maxWidth: '500px',
    margin: '50px auto',
    padding: '20px',
    fontFamily: 'Arial, sans-serif',
    border: '1px solid #ccc',
    borderRadius: '8px',
    boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
  },
  form: {
    display: 'flex',
    flexDirection: 'column',
    gap: '15px',
  },
  input: {
    padding: '10px',
    fontSize: '1rem',
    border: '1px solid #ccc',
    borderRadius: '4px',
  },
  button: {
    padding: '12px 15px',
    fontSize: '1rem',
    backgroundColor: '#007bff',
    color: 'white',
    border: 'none',
    borderRadius: '4px',
    cursor: 'pointer',
    transition: 'background-color 0.2s ease',
  },
   secondaryButton: {
     backgroundColor: '#6c757d', // A different color for secondary actions
   },
  errorMessage: {
    color: 'red',
    backgroundColor: '#ffebee',
    padding: '10px',
    borderRadius: '4px',
    border: '1px solid red',
  },
  successMessage: {
    color: 'green',
    backgroundColor: '#e8f5e9',
    padding: '10px',
    borderRadius: '4px',
    border: '1px solid green',
  },
};

Explanation:

  1. 'use client': Directive required by Next.js App Router for client-side hooks.
  2. State Variables: Manage phone number, code, request ID, UI step, loading state, and feedback messages.
  3. BACKEND_URL: Points to the Express API. Uses NEXT_PUBLIC_BACKEND_URL if set.
  4. handleRequestCode: Makes the POST request to /api/request-verification. On success, stores the requestId (with the security caveat mentioned) and moves to the 'check' step.
  5. handleCheckCode: Makes the POST request to /api/check-verification with the stored requestId and the entered code. Shows success or error messages.
  6. Render Logic: Conditionally displays the correct form based on verificationStep. Uses basic inline styles and provides user feedback. Input type attributes are corrected to use standard quotes (e.g., type="tel").

3.2. Environment Variable for Frontend (Optional but Recommended):

Create a .env.local file in the frontend directory to specify the backend URL during development:

bash
# frontend/.env.local
NEXT_PUBLIC_BACKEND_URL=http://localhost:5001

NEXT_PUBLIC_ prefix makes this variable accessible in the browser-side code. IMPORTANT: After creating or modifying the frontend/.env.local file, you must restart your Next.js development server (npm run dev) for the changes to take effect.


How to Test Your OTP Authentication System

  1. Start the Backend Server: Open a terminal, navigate to the backend directory, and run:

    bash
    node server.js

    You should see Backend server listening at http://localhost:5001.

  2. Start the Frontend Server: Open another terminal, navigate to the frontend directory, and run:

    bash
    npm run dev

    You should see output indicating the Next.js server is running, typically at http://localhost:3000. Remember to restart this if you just created/modified .env.local.

  3. Test the SMS OTP Flow:

    • Open http://localhost:3000 in your browser.
    • Enter your phone number (including country code, e.g., +1...) and click "Send Code".
    • Check your phone for the 6-digit SMS code (or voice call if SMS fails). You should see logs in the backend terminal indicating the request.
    • Enter the received code into the second form and click "Verify Code".
    • You should see a success message if the code is correct, or an error message otherwise. Check the backend logs for details on success or failure.

Best Practices for Error Handling and Logging

  • Backend Error Handling:
    • We use try...catch with async/await to handle errors from the Vonage SDK V3+.
    • We inspect error.response (specifically error.response.status and error.response.data) to map Vonage API errors (like 400, 410, 429) to user-friendly messages and appropriate HTTP status codes.
    • Production Enhancement: Use a dedicated logging library (like Winston or Pino) instead of console.log/console.error for structured logging (JSON format), different log levels (info, warn, error), and outputting logs to files or external services.
    • Consult the Vonage Verify API V2 Reference for detailed error responses.
  • Frontend Error Handling:
    • The frontend checks response.ok and uses the JSON error message from the backend (data.error).
    • Display clear, non-technical error messages to the user. Avoid exposing raw error details.
    • Guide the user on what to do next (e.g., "check the code", "request a new code").
  • Retry Mechanisms:
    • Vonage Internal Retries: Verify V2 workflows can include retries (e.g., SMS -> Voice). Configure workflows as needed via the Vonage API or Dashboard.
    • User-Initiated Retries: The frontend allows the user to effectively retry by going back ("Change Phone Number") or implicitly if they enter the wrong code and try again. You could add an explicit "Resend Code" button which would call /api/request-verification again (essential to implement rate limiting).
    • Backend API Call Retries: For transient network errors when the backend calls Vonage, consider implementing exponential backoff retries (e.g., using libraries like async-retry), especially if network reliability is a concern.

Database Schema and Secure Session Management

This simple OTP flow doesn't strictly require a database. However, in a real-world application integrating 2FA into a user system, and for more secure handling of the requestId:

  • User Table: You'd typically have a users table.
  • 2FA Status: Add columns like is_2fa_enabled (boolean) and phone_number_for_2fa to the users table.
  • Secure requestId Handling (Recommended): The approach of sending requestId to the client (used in this guide for simplicity) is less secure because the client controls it. A malicious user could potentially try to check codes against different requestIds. The recommended, more secure pattern uses server-side sessions:
    1. User authenticates partially (e.g., logs in with password, or is registering).
    2. Backend API /api/request-verification is called.
    3. Backend stores the received requestId in the user's server-side session (e.g., using express-session backed by Redis or a database store). Do NOT send the requestId back to the client.
    4. Frontend shows the code input form (it doesn't know or need the requestId).
    5. Frontend POSTs only the code to /api/check-verification.
    6. Backend retrieves the requestId from the user's current session.
    7. Backend calls vonage.verify.check using the session requestId and the submitted code.
    8. If successful, mark the session as fully authenticated (2FA passed). This prevents the client from manipulating the requestId during the check step.
  • Migrations: Use a migration tool (like knex migrations or Prisma Migrate) if adding 2FA fields to your database schema.

Essential Security Features for Production

  • Input Validation & Sanitization:

    • Backend: Use a library like express-validator to rigorously validate inputs (phoneNumber format/length using E.164 checks, code format/length, requestId format if applicable). Sanitize inputs where necessary.
    • Frontend: Basic HTML5 validation (required, type="tel", pattern) provides initial checks but never rely solely on frontend validation.
  • Rate Limiting: CRITICAL for OTP endpoints.

    • Apply rate limiting to /api/request-verification (per user ID if logged in, or per IP address) to prevent SMS pumping fraud and abuse.
    • Apply stricter rate limiting to /api/check-verification (per user ID/session, or per IP) to prevent brute-force guessing of codes.
    • Use a library like express-rate-limit (v8.1.0+ as of 2025).
    javascript
    // Example in backend/server.js (express-rate-limit v8+)
    const { rateLimit } = require('express-rate-limit');
    
    const requestLimiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        limit: 5, // Limit each IP/user to 5 requests per windowMs
        message: 'Too many verification requests, please try again after 15 minutes',
        standardHeaders: 'draft-8', // Use combined RateLimit header
        legacyHeaders: false, // Disable X-RateLimit-* headers
        // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available
    });
    
    const checkLimiter = rateLimit({
        windowMs: 5 * 60 * 1000, // 5 minutes
        limit: 5, // Limit each IP/user to 5 check attempts per windowMs
        message: 'Too many verification attempts, please try again later.',
        standardHeaders: 'draft-8',
        legacyHeaders: false,
         // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available
    });
    
    app.post('/api/request-verification', requestLimiter, async (req, res) => { /* ... handler ... */ });
    app.post('/api/check-verification', checkLimiter, async (req, res) => { /* ... handler ... */ });
  • HTTPS: Always use HTTPS in production to encrypt data in transit. Configure your hosting environment (Vercel, Heroku, Nginx, etc.) to enforce HTTPS.

  • API Key Security: Never commit API keys or secrets directly into your code or version control. Use .env files (added to .gitignore as shown in setup) and configure environment variables securely in your deployment environment.

  • CORS Configuration: In production, configure cors restrictively to only allow requests from your specific frontend domain(s).

    javascript
    // Example restrictive CORS for production in backend/server.js
    const allowedOrigins = ['https://your-frontend-domain.com']; // Replace
    const corsOptions = {
      origin: function (origin, callback) {
        if (!origin || allowedOrigins.indexOf(origin) !== -1) {
          callback(null, true);
        } else {
          callback(new Error('Not allowed by CORS'));
        }
      },
      optionsSuccessStatus: 200 // For legacy browser support
    };
    
    app.use(cors(corsOptions)); // Apply restrictive CORS options
  • Secure Session Management: If implementing the recommended server-side requestId handling (Section 6), use secure session middleware (express-session) with:

    • A strong, randomly generated secret.
    • Secure cookie settings (httpOnly: true, secure: true (requires HTTPS), sameSite: 'Lax' or 'Strict').
    • A suitable session store for production (like Redis or a database store, not the default memory store).
  • Vonage Security Features: Explore Vonage Verify V2 features like fraud detection, workflow customization (e.g., SMS then Voice fallback), and Silent Authentication for enhanced security and user experience.


Frequently Asked Questions About Vonage OTP Authentication

What is Vonage Verify API V2 and how does it differ from V1?

Vonage Verify API V2 provides enhanced two-factor authentication with multiple channels (SMS, Voice, WhatsApp, Email), improved anti-fraud protection, custom webhooks, and a modernized API structure. Unlike V1, it uses a workflow-based approach and supports Silent Authentication for frictionless verification.

What Node.js version should I use for Vonage Verify API V2 in 2025?

Use Node.js v20 (Active LTS) or v22 (Active LTS) for production applications. Node.js v18 reached End-of-Life on March 27, 2025 and should no longer be used. Visit the official Node.js releases page for current LTS status.

How do I get Vonage API credentials?

Sign up for a free Vonage account at the Vonage API Dashboard. After registration, you'll find your API Key and API Secret in the "Getting Started" section of your dashboard. Store these securely in your .env file.

What is the maximum brand name length for Vonage Verify V2?

The brand name can be up to 18 characters long and must match the regex pattern ^[^\/{}:$]*$. This is the name displayed as the sender in OTP messages, helping users identify the source of the verification code.

How many digits should an OTP code be?

The Vonage Verify V2 API supports OTP codes between 4 and 10 digits. The recommended length is 6 digits, which provides a good balance between security (1 million combinations) and user convenience. Configure this with the code_length parameter.

What HTTP status codes does Vonage Verify V2 return?

Common status codes include: 200 (success with {request_id, status: "completed"}), 400 (invalid code or validation error), 409 (concurrent verification not allowed), 410 (request expired or already used), and 429 (rate limit exceeded). Handle these appropriately in your error handling logic.

Why shouldn't I send the requestId to the client?

Sending the requestId to the client allows malicious users to potentially check codes against different request IDs or manipulate the verification process. The secure approach stores the requestId in a server-side session and only sends the verification code to the client.

How do I implement rate limiting for OTP endpoints?

Use the express-rate-limit library (v8.1.0+) to limit requests per IP or user ID. Recommended limits: 5 verification requests per 15 minutes and 5 code check attempts per 5 minutes. This prevents SMS pumping fraud and brute-force attacks.

What is E.164 phone number format?

E.164 is the international standard for phone numbers, starting with a + symbol followed by country code and subscriber number (e.g., +14155552671). Vonage Verify V2 requires phone numbers in E.164 format for proper routing across international carriers.

Can I use Vonage Verify V2 with TypeScript and Tailwind CSS?

Yes, this guide uses plain JavaScript and no CSS framework for simplicity, but you can easily adapt it for TypeScript (add --typescript=yes to the Next.js setup command) and Tailwind CSS (add --tailwind=yes). The Vonage SDK supports TypeScript out of the box.

How do I handle concurrent verification requests?

Vonage Verify V2 prevents concurrent verifications to the same phone number (HTTP 409 error). Inform users they must complete or cancel the current verification before requesting a new one. Implement this in your error handling with a user-friendly message.

What production security measures should I implement?

Essential security measures include: HTTPS enforcement, restrictive CORS configuration, secure session management with Redis or database-backed sessions, input validation with express-validator, comprehensive rate limiting, proper error handling without exposing sensitive details, and storing the requestId server-side only.

Frequently Asked Questions

How to set up Vonage 2FA with Next.js?

Set up separate frontend (Next.js) and backend (Node/Express) projects. Install required dependencies like `express`, `@vonage/server-sdk`, `dotenv`, and `cors` in the backend. Create a `.env` file in the backend to store your Vonage API credentials and brand name securely. Initialize a Next.js project using `create-next-app` for the frontend.

What is Vonage Verify API V2 used for?

Vonage Verify API V2 is a service for sending and verifying one-time passcodes (OTPs) via SMS and voice calls. It simplifies adding two-factor authentication (2FA) to web applications by providing a managed and reliable global service for OTP delivery and verification, reducing the complexity of building 2FA from scratch.

Why does two-factor authentication improve security?

2FA adds a layer of security beyond passwords. By requiring users to possess their phone to receive a time-sensitive code, it significantly reduces unauthorized access even if passwords are compromised. This makes it much harder for attackers to gain access to user accounts, even if they manage to obtain the user's password through phishing or other means.

When should I store the requestId server-side?

Always store the `requestId` server-side in a secure session for production applications. While this guide sends it to the client for demonstration simplicity, this is less secure and could be exploited. Securely storing the `requestId` prevents clients from manipulating it during the check process and helps prevent unauthorized use of the verification code. Use appropriate session management and secure cookie settings in a production environment.

Can I customize the length of the OTP code?

Yes, you can request a 6-digit code by setting `code_length: 6` when calling `vonage.verify.start()`. While the default for Verify API V1 is 4 digits, 6 is more common and provides slightly better security by increasing the number of possible combinations.

How to implement Vonage OTP request in Express?

Create a POST route (e.g., `/api/request-verification`) in your Express backend. Extract the user's phone number from the request body, validate it, and call `vonage.verify.start()` with the number, brand name, and desired code length. Handle errors appropriately, and in a production setting, store the `requestId` in the user's server-side session.

What is the purpose of rate limiting OTP requests?

Rate limiting is crucial to prevent abuse. Limit requests to `/api/request-verification` (e.g., 5 requests per 15 minutes per IP/user) to prevent SMS pumping fraud. Implement stricter limits for `/api/check-verification` (e.g., 5 attempts per 5 minutes) to mitigate brute-force code guessing. Use libraries like `express-rate-limit` for straightforward implementation.

How to verify the Vonage OTP code on the backend?

Create a POST route (e.g., `/api/check-verification`) to receive the OTP code and retrieve the `requestId` from the user's session (secure approach). Call `vonage.verify.check()` with the `requestId` and code. A successful check returns a 200 OK status *without* throwing an error. Handle any errors to provide specific feedback to the user.

How to handle errors from the Vonage Verify API?

The Vonage Node.js SDK V3+ throws errors on API failures. Use `try...catch` blocks to handle them. Inspect `error.response.data` (for API errors) and `error.response.status` for specific error codes (e.g., 400, 410, 429) and provide informative error messages to the user without revealing sensitive details. Use appropriate logging for debugging and monitoring.

What are best practices for securing Vonage API credentials?

Never commit API keys or secrets directly into your codebase. Store them in a `.env` file (which should be added to your `.gitignore`) and load them using the `dotenv` library. Configure environment variables securely in your deployment environment to protect your credentials.

How to improve frontend input validation for phone numbers?

Use HTML input validation such as `type="tel"`, `required` attribute, and input patterns to enforce basic format and improve UX. While client-side validation improves user experience by providing immediate feedback, always perform server-side validation for security.

Why is Cross-Origin Resource Sharing (CORS) important?

CORS is critical when your frontend (Next.js) and backend (Express) run on different ports during development or different domains in production. Configure CORS in your backend using the `cors` middleware, allowing requests from the appropriate origin(s). In production, restrict CORS to your frontend's domain to enhance security.

What is the recommended way to handle the requestId securely?

The most secure approach is to store the `requestId` in a server-side session associated with the user and never send it to the client. When checking the code, retrieve the `requestId` from the session, preventing client-side manipulation.

What database schema is recommended for user authentication with 2FA?

A `users` table should contain columns for standard user information (like username, password hash). To incorporate 2FA, include columns such as `is_2fa_enabled` (boolean) and `phone_number_for_2fa`. Use database migrations to manage schema changes effectively.

How to resend verification code in a secure way?

Implement a 'Resend Code' button on the frontend. Upon clicking, make a request to your backend's `/api/request-verification` endpoint with the phone number (same logic as initial request), implementing rate limiting to prevent abuse. Update the user's session with the new requestId if storing it server-side. In the case of client-side requestId storage, send the new requestId to the client. Always enforce rate limits to prevent abuse.