Framework Note: This guide demonstrates OTP/2FA implementation using Node.js with Express.js. While the title references Next.js and Supabase, this implementation focuses on Express.js backend patterns that adapt to Next.js API routes and integrate with Supabase Auth for user management. Express.js provides simplicity and widespread adoption in backend API development, making the core concepts transferable across frameworks.
This guide provides a step-by-step walkthrough for building a production-ready One-Time Password (OTP) Two-Factor Authentication (2FA) system using SMS delivery. You'll leverage Node.js with the Express framework and the Vonage Verify API for a robust and simplified implementation.
By the end of this tutorial, you'll have a functional application that:
Accepts a user's phone number.
Initiates an OTP request to that number via SMS using the Vonage Verify API.
Allows the user to submit the received OTP code.
Verifies the submitted code against the Vonage Verify API.
Provides feedback on the verification status (success or failure).
This guide focuses on the core backend logic and API endpoints necessary for SMS OTP verification.
Project Overview and Goals
Goal: Implement a secure and reliable SMS-based 2FA mechanism for user verification within a Node.js/Express application.
Problem Solved: Standard password authentication remains vulnerable to multiple attack vectors including credential stuffing (automated injection of stolen username/password pairs), phishing attacks, brute-force attacks, and password reuse exploits (source: OWASP Multifactor Authentication Cheat Sheet). Microsoft research indicates that MFA would have prevented 99.9% of account compromises (source). Adding 2FA via SMS OTP significantly enhances security by requiring users to possess their registered phone ("something they have") in addition to their password ("something they know"). This guide provides the backend infrastructure for this second factor.
Security Limitations: While SMS-based 2FA is significantly better than password-only authentication, it remains susceptible to SIM swapping attacks, SMS interception, and phishing. For high-security applications, consider TOTP authenticator apps or hardware tokens (source: OWASP MFA Testing Guide).
Technologies Used:
Node.js: A JavaScript runtime environment ideal for building scalable network applications. Minimum version required: Node.js 18 LTS (as of the latest @vonage/server-sdk requirements – source).
Express.js: A minimal and flexible Node.js web application framework, providing robust features for web and mobile applications.
Vonage Verify API: A purpose-built API by Vonage that handles OTP generation, delivery (including SMS, voice fallbacks, and retries), and verification logic, simplifying the developer's task.
@vonage/server-sdk: The official Vonage Node.js SDK for interacting with Vonage APIs, including the Verify API (npm package).
dotenv: A module to load environment variables from a .env file into process.env.
body-parser: Node.js middleware for parsing incoming request bodies. While newer versions of Express (4.16+) have built-in middleware (express.json(), express.urlencoded()), including body-parser explicitly ensures compatibility or can make parsing setup clearer for some developers.
Compatibility Note: The @vonage/server-sdk requires Node.js 18 LTS or higher. Express.js 4.x is compatible with all LTS Node.js versions.
Why Vonage Verify API?
Instead of manually generating codes, managing delivery attempts, handling expiry, and implementing complex verification logic, the Vonage Verify API encapsulates this entire workflow. It provides:
Secure code generation.
Multi-channel delivery attempts (SMS primary, often with voice fallback).
Built-in code expiry and retry logic.
Simplified verification checks via API calls.
Protection against certain types of abuse.
Rate limiting built into the API (maximum 30 requests per second – source).
This significantly reduces development time and potential security pitfalls compared to building an OTP system from scratch.
System Architecture:
Diagram Description:
The user initiates the 2FA process (e.g., after login) by providing their phone number to the Node.js application via an HTTPS API call.
The Node.js application calls the Vonage Verify API's start endpoint with the user's number.
Vonage generates an OTP and sends it via SMS to the user's phone. Vonage returns a request_id to the Node.js application.
The Node.js application prompts the user (via the frontend) to enter the OTP code they received.
The user submits the OTP code and implicitly identifies the verification attempt (e.g., via their session or phone number) back to the Node.js application via another HTTPS API call.
The Node.js application retrieves the relevant request_id and calls the Vonage Verify API's check endpoint with the request_id and the submitted code.
Vonage verifies the code and returns the result (success or failure) to the Node.js application.
The Node.js application informs the user of the outcome.
Security Note: All communication between the client and server must use HTTPS/TLS to prevent man-in-the-middle (MITM) attacks that could intercept OTP codes or session tokens. Communication between your server and Vonage API is automatically secured via HTTPS.
Prerequisites:
Node.js and npm (or yarn): Version 18 LTS or higher installed on your development machine. (Download Node.js)
Vonage API Account: A free account is sufficient to start. (Sign up for Vonage)
You'll need your API Key and API Secret found on the Vonage API Dashboard homepage after signing in.
@vonage/server-sdk: To interact with the Vonage APIs (latest version recommended).
dotenv: To manage sensitive credentials like API keys.
body-parser: Middleware to parse JSON request bodies.
express-rate-limit: Middleware for rate limiting to prevent abuse (npm package).
libphonenumber-js: Library for phone number validation and formatting (npm package).
1.4 Create Project Structure:
Create the following basic file structure:
Code
vonage-otp-guide/
├── node_modules/
├── .env # For environment variables (API keys) – DO NOT COMMIT TO GIT
├── .gitignore # To exclude node_modules and .env from Git
├── index.js # Main application file
├── package.json
└── package-lock.json
1.5 Configure .gitignore:
Create a .gitignore file in the root directory and add the following lines to prevent committing sensitive information and unnecessary files:
Text
# .gitignore
node_modules
.env
*.log
1.6 Set Up Environment Variables (.env):
Create a file named .env in the root directory. Find your API Key and API Secret on the Vonage API Dashboard.
.env
# .env
VONAGE_API_KEY=YOUR_API_KEY_HERE
VONAGE_API_SECRET=YOUR_API_SECRET_HERE
# Optional: Set a default port
PORT=3000
Replace YOUR_API_KEY_HERE and YOUR_API_SECRET_HERE with your actual credentials from the Vonage dashboard.
Vonage Free Tier: New Vonage accounts receive €2 free credit. Each verification attempt costs approximately €0.05-0.15 depending on destination country, allowing 13-40 test verifications. Check current pricing at Vonage Pricing.
Purpose: Using environment variables is crucial for security. It prevents hardcoding sensitive credentials directly into your source code. dotenv loads these variables into process.env when the application starts.
Now, write the core logic in index.js to interact with the Vonage Verify API.
2.1 Initialize Express and Vonage SDK:
Open index.js and add the following setup code:
JavaScript
// index.jsrequire('dotenv').config(); // Load environment variables from .env fileconst express = require('express');
const bodyParser = require('body-parser');
const { Vonage } = require('@vonage/server-sdk');
const rateLimit = require('express-rate-limit');
const { parsePhoneNumber, isValidPhoneNumber } = require('libphonenumber-js');
// --- Configuration ---const app = express();
const port = process.env.PORT || 3000; // Use port from .env or default to 3000// --- Vonage Client Initialization ---// IMPORTANT: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in your .env fileif (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('⚠️ Error: VONAGE_API_KEY or VONAGE_API_SECRET not found in .env file.');
console.error('Add them to your .env file.');
process.exit(1); // Exit if keys are missing
}
const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// --- Rate Limiting Configuration ---// Prevent abuse of OTP requests – max 5 requests per 15 minutes per IPconst otpRequestLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 5, // Limit each IP to 5 requests per windowMsmessage: { error: 'Too many OTP requests from this IP. Try again after 15 minutes.' },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Verification check rate limiter – max 10 attempts per 5 minutes per IPconst otpCheckLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutesmax: 10, // Allow up to 10 check attempts per windowMs (multiple users per IP)message: { error: 'Too many verification attempts. Try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// --- Middleware ---// Parse JSON bodies (as sent by API clients)
app.use(bodyParser.json());
// Parse URL-encoded bodies (as sent by HTML forms)
app.use(bodyParser.urlencoded({ extended: true }));
// In-memory storage for demo purposes.// Production apps should use a database or persistent cache (e.g., Redis).// Structure: { phoneNumber: { requestId: string, attempts: number, lastAttempt: Date } }const verificationRequests = {};
// --- Helper Functions ---/**
* Validates and formats phone number to E.164 format
* @param {string} phoneNumber – Raw phone number input
* @returns {object} – { valid: boolean, formatted: string, error: string }
*/functionvalidateAndFormatPhone(phoneNumber) {
try {
// Check if it's a valid phone numberif (!isValidPhoneNumber(phoneNumber)) {
return { valid: false, error: 'Invalid phone number format' };
}
// Parse and format to E.164const parsed = parsePhoneNumber(phoneNumber);
return {
valid: true,
formatted: parsed.format('E.164') // e.g., +14155552671
};
} catch (error) {
return {
valid: false,
error: 'Unable to parse phone number. Use international format (e.g., +14155552671).'
};
}
}
// --- Routes (will be added below) ---// ...// --- Health Check Endpoint ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', service: 'vonage-otp-api' });
});
// --- Start Server ---
app.listen(port, () => {
console.log(`✅ Server running on http://localhost:${port}`);
console.log(`📋 Health check: http://localhost:${port}/health`);
});
// --- Graceful Shutdown ---
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
});
Explanation:
require('dotenv').config();: Loads variables from the .env file. Call this early in your application.
Imports express, body-parser, and the Vonage class from the SDK, plus rate limiting and phone validation libraries.
Initializes the express app and sets the port.
Crucially, initializes the Vonage client using the API key and secret loaded from process.env. Includes a check to ensure the keys are present.
Configures rate limiting middleware to prevent abuse: 5 OTP requests per 15 minutes per IP for /request-otp, and 10 verification attempts per 5 minutes per IP for /check-otp (OWASP recommendation).
Sets up bodyParser middleware to correctly parse incoming JSON and URL-encoded request bodies.
verificationRequests: A simple JavaScript object used as temporary storage to map phone numbers to the request_id returned by Vonage. Note: This is for demonstration only. In production, use Redis or a database.
Adds a validateAndFormatPhone() helper function using libphonenumber-js to validate and convert phone numbers to E.164 format, which Vonage requires.
Includes /health endpoint for monitoring and load balancer health checks.
Implements graceful shutdown handling for production deployments.
2.2 Implement OTP Request Route (/request-otp):
Add the following route handler in index.js within the // --- Routes --- section:
JavaScript
// index.js (continued)// --- Routes ---/**
* @route POST /request-otp
* @description Initiates an OTP verification request via SMS.
* @param {string} phoneNumber – The user's phone number in E.164 format (e.g., +14155552671).
* @returns {object} 200 – Success message (OTP sent).
* @returns {object} 400 – Bad request (e.g., missing phone number).
* @returns {object} 429 – Too many requests (rate limit exceeded).
* @returns {object} 500 – Server error or Vonage API error.
*/
app.post('/request-otp', otpRequestLimiter, async (req, res) => {
const { phoneNumber } = req.body;
// Input validationif (!phoneNumber) {
return res.status(400).json({ error: 'Phone number is required.' });
}
// Validate and format phone number to E.164const phoneValidation = validateAndFormatPhone(phoneNumber);
if (!phoneValidation.valid) {
return res.status(400).json({ error: phoneValidation.error });
}
const formattedNumber = phoneValidation.formatted;
console.log(`📱 Requesting OTP for: ${formattedNumber}`);
// Check for duplicate concurrent requestsif (verificationRequests[formattedNumber]) {
const existingRequest = verificationRequests[formattedNumber];
const timeSinceLastRequest = Date.now() - existingRequest.lastAttempt.getTime();
// Prevent requests within 60 seconds of previous requestif (timeSinceLastRequest < 60000) {
return res.status(429).json({
error: 'Wait before requesting a new code.',
retryAfter: Math.ceil((60000 - timeSinceLastRequest) / 1000)
});
}
}
try {
const result = await vonage.verify.start({
number: formattedNumber,
brand: 'YourAppName', // IMPORTANT: Customize this! Appears in the SMS message.code_length: 6, // 6-digit code (options: 4 or 6)workflow_id: 6// SMS only, no voice fallback (see workflow section)// See Vonage Docs: https://developer.vonage.com/api/verify#startVerification
});
if (result.status === '0') { // Status '0' indicates success// Store the request_id temporarily, associated with the phone number
verificationRequests[formattedNumber] = {
requestId: result.request_id,
attempts: 0,
lastAttempt: newDate()
};
console.log(`✅ OTP request successful. Request ID: ${result.request_id}`);
// IMPORTANT: Do NOT send the request_id to the client-side
res.status(200).json({
message: 'OTP requested successfully. Check your phone.',
expiresIn: 300// Code expires in 300 seconds (5 minutes)
});
} else {
// Handle Vonage API errorsconsole.error(`❌ Vonage Verify API Error: Status ${result.status} – ${result.error_text}`);
// Map specific Vonage status codes to user-friendly messagesconst errorMessages = {
'1': 'Service temporarily unavailable. Try again.',
'3': 'Invalid phone number format.',
'4': 'Authentication failed. Contact support.',
'7': 'This number is blocked from verification.',
'9': 'Verification quota exceeded. Contact support.',
'10': 'A verification request is already in progress for this number.',
'15': 'This destination is not supported.'
};
const userMessage = errorMessages[result.status] || `Failed to request OTP: ${result.error_text}`;
res.status(result.status === '9' ? 402 : 500).json({ error: userMessage });
}
} catch (error) {
// Handle errors thrown by the SDK or network issuesconsole.error('❌ Server Error requesting OTP:', error);
res.status(500).json({ error: 'An unexpected server error occurred while requesting the OTP.' });
}
});
// ... (other routes and server start)
Key Improvements:
Phone Number Validation: Uses libphonenumber-js to validate and format numbers to E.164 before sending to Vonage API.
Rate Limiting: Applied via middleware to prevent abuse (5 requests per 15 minutes per IP).
Duplicate Request Prevention: Checks if a recent request exists and enforces 60-second cooldown.
Enhanced Error Handling: Maps Vonage error codes to user-friendly messages based on official documentation.
Security: Never exposes request_id to the client.
Workflow Configuration: Uses workflow_id: 6 (SMS only) to avoid unexpected voice calls. Default workflow (1) includes voice fallback after 125 seconds (source).
You've now defined the core API endpoints with security enhancements:
GET /health: Health check endpoint for monitoring.
POST /request-otp: Initiates the verification process (rate limited: 5 per 15 min per IP).
POST /check-otp: Verifies the submitted code (rate limited: 10 per 5 min per IP).
Vonage Verify Workflow Configuration:
The Vonage Verify API supports 7 predefined workflows for delivering OTP codes (source):
workflow_id
Delivery Sequence
Default Timings
Use Case
1 (default)
SMS → TTS → TTS
125s → 180s → 300s
Maximum deliverability
2
SMS → SMS → TTS
125s → 180s → 300s
SMS preference with voice backup
3
TTS → TTS
150s → 150s
Voice-only (accessibility)
4
SMS → SMS
120s → 180s
SMS-only with retry
5
SMS → TTS
120s → 180s
Single SMS then voice
6
SMS only
Expires after 300s
Recommended: SMS-only, no voice
7
TTS only
Expires after 300s
Voice-only
Recommendation: Use workflow_id: 6 (SMS-only) to avoid unexpected voice calls and associated costs. The default workflow (1) will attempt voice calls after 125 seconds if the code isn't verified.
Security Considerations – HTTPS Requirement:
All production deployments must use HTTPS to prevent:
This guide provides a comprehensive implementation of SMS-based OTP 2FA using Node.js, Express, and the Vonage Verify API. The implementation includes essential security features like rate limiting, input validation, and proper error handling. For production use, migrate from in-memory storage to Redis or a database, implement proper user session management, deploy over HTTPS, and consider the advanced security features discussed.
While SMS 2FA is a significant security improvement over password-only authentication, consider offering users the option to upgrade to TOTP-based authenticators or hardware security keys for enhanced protection against SIM-swap and phishing attacks.
Frequently asked questions
Integrate the Vonage Verify API into your Node.js/Express app to implement two-factor authentication. This involves setting up API endpoints to request an OTP, which is sent to the user's phone via SMS, and then verifying the OTP submitted by the user. The Vonage API handles code generation, delivery, and verification.
The Vonage Verify API simplifies the implementation of two-factor authentication (2FA) by generating, delivering (via SMS and potentially voice), and verifying one-time passwords (OTPs). It streamlines the process and reduces development time compared to building an OTP system from scratch.
Vonage Verify API handles secure code generation, multi-channel delivery, retries, code expiry, and verification, reducing development time and potential security risks. It simplifies a complex process into easy API calls, offering a more robust solution than building it yourself.
While Express 4.16+ has built-in body parsing middleware, explicitly using body-parser can improve clarity or ensure compatibility, especially in projects using older Express versions or when more explicit parsing configuration is needed.
Yes, you can customize the sender name (brand) that appears in the SMS message sent by the Vonage Verify API. When calling vonage.verify.start(), set the brand parameter to your application's name. This clearly identifies the source of the OTP to the user.
Make a POST request to the /request-otp endpoint of your Node.js application, providing the user's phone number in E.164 format (e.g., +14155552671) in the request body. The server will then interact with the Vonage Verify API to initiate the OTP process.
Send a POST request to your server's /check-otp endpoint including both the phone number and the received OTP code. The backend will compare this code against the Vonage Verify API using the associated request ID. Never expose the request_id directly to the client for security best practices.
The Vonage API Key and Secret are your credentials to access the Vonage APIs, including the Verify API. Find them on your Vonage API Dashboard. Store them securely, typically in a .env file, never directly in your code.
The Vonage Verify API returns a status code in its responses. '0' indicates success. Non-zero statuses represent specific errors. Check the result.status and result.error_text to identify the cause of the failure and handle appropriately.
The user sends their phone number; the Node.js app requests an OTP from Vonage; Vonage sends an SMS to the user; the user submits the code; the app verifies the code with Vonage and sends back success/failure.
Using a database or cache ensures data isn't lost if the server restarts, making the system scalable. It also allows secure linking between verification requests and individual user sessions.
Add columns to your Users table to store the Vonage request_id and an optional expiry timestamp. Alternatively, use a separate table linked to user_id to store pending verification details.
If the initial SMS fails, Vonage will fallback to a text-to-speech (TTS) phone call that reads out the code. You can also configure custom workflows for different fallback strategies.
Framework Note: This guide demonstrates OTP/2FA implementation using Node.js with Express.js. While the title references Next.js and Supabase, this implementation focuses on Express.js backend patterns that adapt to Next.js API routes and integrate with Supabase Auth for user management. Express.js provides simplicity and widespread adoption in backend API development, making the core concepts transferable across frameworks.
This guide provides a step-by-step walkthrough for building a production-ready One-Time Password (OTP) Two-Factor Authentication (2FA) system using SMS delivery. You'll leverage Node.js with the Express framework and the Vonage Verify API for a robust and simplified implementation.
By the end of this tutorial, you'll have a functional application that:
This guide focuses on the core backend logic and API endpoints necessary for SMS OTP verification.
Project Overview and Goals
Goal: Implement a secure and reliable SMS-based 2FA mechanism for user verification within a Node.js/Express application.
Problem Solved: Standard password authentication remains vulnerable to multiple attack vectors including credential stuffing (automated injection of stolen username/password pairs), phishing attacks, brute-force attacks, and password reuse exploits (source: OWASP Multifactor Authentication Cheat Sheet). Microsoft research indicates that MFA would have prevented 99.9% of account compromises (source). Adding 2FA via SMS OTP significantly enhances security by requiring users to possess their registered phone ("something they have") in addition to their password ("something they know"). This guide provides the backend infrastructure for this second factor.
Security Limitations: While SMS-based 2FA is significantly better than password-only authentication, it remains susceptible to SIM swapping attacks, SMS interception, and phishing. For high-security applications, consider TOTP authenticator apps or hardware tokens (source: OWASP MFA Testing Guide).
Technologies Used:
@vonage/server-sdk: The official Vonage Node.js SDK for interacting with Vonage APIs, including the Verify API (npm package).dotenv: A module to load environment variables from a.envfile intoprocess.env.body-parser: Node.js middleware for parsing incoming request bodies. While newer versions of Express (4.16+) have built-in middleware (express.json(),express.urlencoded()), includingbody-parserexplicitly ensures compatibility or can make parsing setup clearer for some developers.Compatibility Note: The
@vonage/server-sdkrequires Node.js 18 LTS or higher. Express.js 4.x is compatible with all LTS Node.js versions.Why Vonage Verify API?
Instead of manually generating codes, managing delivery attempts, handling expiry, and implementing complex verification logic, the Vonage Verify API encapsulates this entire workflow. It provides:
This significantly reduces development time and potential security pitfalls compared to building an OTP system from scratch.
System Architecture:
Diagram Description:
startendpoint with the user's number.request_idto the Node.js application.request_idand calls the Vonage Verify API'scheckendpoint with therequest_idand the submitted code.Security Note: All communication between the client and server must use HTTPS/TLS to prevent man-in-the-middle (MITM) attacks that could intercept OTP codes or session tokens. Communication between your server and Vonage API is automatically secured via HTTPS.
Prerequisites:
curlor Postman: For testing the API endpoints.1. Setting Up the Project
Create the basic project structure and install the necessary dependencies.
1.1 Create Project Directory:
Open your terminal or command prompt and create a new directory for your project.
mkdir vonage-otp-guide cd vonage-otp-guide1.2 Initialize Node.js Project:
Initialize the project using npm. The
-yflag accepts the default settings.This creates a
package.jsonfile.1.3 Install Dependencies:
Install Express, the Vonage Server SDK,
dotenvfor environment variables, andbody-parser.Optional but Recommended for Production:
express: The web framework.@vonage/server-sdk: To interact with the Vonage APIs (latest version recommended).dotenv: To manage sensitive credentials like API keys.body-parser: Middleware to parse JSON request bodies.express-rate-limit: Middleware for rate limiting to prevent abuse (npm package).libphonenumber-js: Library for phone number validation and formatting (npm package).1.4 Create Project Structure:
Create the following basic file structure:
1.5 Configure
.gitignore:Create a
.gitignorefile in the root directory and add the following lines to prevent committing sensitive information and unnecessary files:1.6 Set Up Environment Variables (
.env):Create a file named
.envin the root directory. Find your API Key and API Secret on the Vonage API Dashboard.Replace
YOUR_API_KEY_HEREandYOUR_API_SECRET_HEREwith your actual credentials from the Vonage dashboard.Vonage Free Tier: New Vonage accounts receive €2 free credit. Each verification attempt costs approximately €0.05-0.15 depending on destination country, allowing 13-40 test verifications. Check current pricing at Vonage Pricing.
Purpose: Using environment variables is crucial for security. It prevents hardcoding sensitive credentials directly into your source code.
dotenvloads these variables intoprocess.envwhen the application starts.2. Implementing Core Functionality (Vonage Verify API)
Now, write the core logic in
index.jsto interact with the Vonage Verify API.2.1 Initialize Express and Vonage SDK:
Open
index.jsand add the following setup code:// index.js require('dotenv').config(); // Load environment variables from .env file const express = require('express'); const bodyParser = require('body-parser'); const { Vonage } = require('@vonage/server-sdk'); const rateLimit = require('express-rate-limit'); const { parsePhoneNumber, isValidPhoneNumber } = require('libphonenumber-js'); // --- Configuration --- const app = express(); const port = process.env.PORT || 3000; // Use port from .env or default to 3000 // --- Vonage Client Initialization --- // IMPORTANT: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in your .env file if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('⚠️ Error: VONAGE_API_KEY or VONAGE_API_SECRET not found in .env file.'); console.error('Add them to your .env file.'); process.exit(1); // Exit if keys are missing } const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // --- Rate Limiting Configuration --- // Prevent abuse of OTP requests – max 5 requests per 15 minutes per IP const otpRequestLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP to 5 requests per windowMs message: { error: 'Too many OTP requests from this IP. Try again after 15 minutes.' }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Verification check rate limiter – max 10 attempts per 5 minutes per IP const otpCheckLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 10, // Allow up to 10 check attempts per windowMs (multiple users per IP) message: { error: 'Too many verification attempts. Try again later.' }, standardHeaders: true, legacyHeaders: false, }); // --- Middleware --- // Parse JSON bodies (as sent by API clients) app.use(bodyParser.json()); // Parse URL-encoded bodies (as sent by HTML forms) app.use(bodyParser.urlencoded({ extended: true })); // In-memory storage for demo purposes. // Production apps should use a database or persistent cache (e.g., Redis). // Structure: { phoneNumber: { requestId: string, attempts: number, lastAttempt: Date } } const verificationRequests = {}; // --- Helper Functions --- /** * Validates and formats phone number to E.164 format * @param {string} phoneNumber – Raw phone number input * @returns {object} – { valid: boolean, formatted: string, error: string } */ function validateAndFormatPhone(phoneNumber) { try { // Check if it's a valid phone number if (!isValidPhoneNumber(phoneNumber)) { return { valid: false, error: 'Invalid phone number format' }; } // Parse and format to E.164 const parsed = parsePhoneNumber(phoneNumber); return { valid: true, formatted: parsed.format('E.164') // e.g., +14155552671 }; } catch (error) { return { valid: false, error: 'Unable to parse phone number. Use international format (e.g., +14155552671).' }; } } // --- Routes (will be added below) --- // ... // --- Health Check Endpoint --- app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', service: 'vonage-otp-api' }); }); // --- Start Server --- app.listen(port, () => { console.log(`✅ Server running on http://localhost:${port}`); console.log(`📋 Health check: http://localhost:${port}/health`); }); // --- Graceful Shutdown --- process.on('SIGTERM', () => { console.log('SIGTERM signal received: closing HTTP server'); server.close(() => { console.log('HTTP server closed'); process.exit(0); }); });Explanation:
require('dotenv').config();: Loads variables from the.envfile. Call this early in your application.express,body-parser, and theVonageclass from the SDK, plus rate limiting and phone validation libraries.expressapp and sets theport.Vonageclient using the API key and secret loaded fromprocess.env. Includes a check to ensure the keys are present./request-otp, and 10 verification attempts per 5 minutes per IP for/check-otp(OWASP recommendation).bodyParsermiddleware to correctly parse incoming JSON and URL-encoded request bodies.verificationRequests: A simple JavaScript object used as temporary storage to map phone numbers to therequest_idreturned by Vonage. Note: This is for demonstration only. In production, use Redis or a database.validateAndFormatPhone()helper function usinglibphonenumber-jsto validate and convert phone numbers to E.164 format, which Vonage requires./healthendpoint for monitoring and load balancer health checks.2.2 Implement OTP Request Route (
/request-otp):Add the following route handler in
index.jswithin the// --- Routes ---section:// index.js (continued) // --- Routes --- /** * @route POST /request-otp * @description Initiates an OTP verification request via SMS. * @param {string} phoneNumber – The user's phone number in E.164 format (e.g., +14155552671). * @returns {object} 200 – Success message (OTP sent). * @returns {object} 400 – Bad request (e.g., missing phone number). * @returns {object} 429 – Too many requests (rate limit exceeded). * @returns {object} 500 – Server error or Vonage API error. */ app.post('/request-otp', otpRequestLimiter, async (req, res) => { const { phoneNumber } = req.body; // Input validation if (!phoneNumber) { return res.status(400).json({ error: 'Phone number is required.' }); } // Validate and format phone number to E.164 const phoneValidation = validateAndFormatPhone(phoneNumber); if (!phoneValidation.valid) { return res.status(400).json({ error: phoneValidation.error }); } const formattedNumber = phoneValidation.formatted; console.log(`📱 Requesting OTP for: ${formattedNumber}`); // Check for duplicate concurrent requests if (verificationRequests[formattedNumber]) { const existingRequest = verificationRequests[formattedNumber]; const timeSinceLastRequest = Date.now() - existingRequest.lastAttempt.getTime(); // Prevent requests within 60 seconds of previous request if (timeSinceLastRequest < 60000) { return res.status(429).json({ error: 'Wait before requesting a new code.', retryAfter: Math.ceil((60000 - timeSinceLastRequest) / 1000) }); } } try { const result = await vonage.verify.start({ number: formattedNumber, brand: 'YourAppName', // IMPORTANT: Customize this! Appears in the SMS message. code_length: 6, // 6-digit code (options: 4 or 6) workflow_id: 6 // SMS only, no voice fallback (see workflow section) // See Vonage Docs: https://developer.vonage.com/api/verify#startVerification }); if (result.status === '0') { // Status '0' indicates success // Store the request_id temporarily, associated with the phone number verificationRequests[formattedNumber] = { requestId: result.request_id, attempts: 0, lastAttempt: new Date() }; console.log(`✅ OTP request successful. Request ID: ${result.request_id}`); // IMPORTANT: Do NOT send the request_id to the client-side res.status(200).json({ message: 'OTP requested successfully. Check your phone.', expiresIn: 300 // Code expires in 300 seconds (5 minutes) }); } else { // Handle Vonage API errors console.error(`❌ Vonage Verify API Error: Status ${result.status} – ${result.error_text}`); // Map specific Vonage status codes to user-friendly messages const errorMessages = { '1': 'Service temporarily unavailable. Try again.', '3': 'Invalid phone number format.', '4': 'Authentication failed. Contact support.', '7': 'This number is blocked from verification.', '9': 'Verification quota exceeded. Contact support.', '10': 'A verification request is already in progress for this number.', '15': 'This destination is not supported.' }; const userMessage = errorMessages[result.status] || `Failed to request OTP: ${result.error_text}`; res.status(result.status === '9' ? 402 : 500).json({ error: userMessage }); } } catch (error) { // Handle errors thrown by the SDK or network issues console.error('❌ Server Error requesting OTP:', error); res.status(500).json({ error: 'An unexpected server error occurred while requesting the OTP.' }); } }); // ... (other routes and server start)Key Improvements:
libphonenumber-jsto validate and format numbers to E.164 before sending to Vonage API.request_idto the client.workflow_id: 6(SMS only) to avoid unexpected voice calls. Default workflow (1) includes voice fallback after 125 seconds (source).Vonage Verify Status Codes Reference:
Source: Vonage Verify API Status Codes
2.3 Implement OTP Check Route (
/check-otp):Add the route handler for verifying the code submitted by the user:
// index.js (continued) /** * @route POST /check-otp * @description Verifies the OTP code submitted by the user. * @param {string} phoneNumber – The user's phone number (used to retrieve request_id). * @param {string} code – The OTP code entered by the user. * @returns {object} 200 – Verification successful. * @returns {object} 400 – Bad request (e.g., missing fields, invalid code format). * @returns {object} 401 – Unauthorized (verification failed – wrong code, expired, etc.). * @returns {object} 404 – Not Found (request ID not found for the phone number). * @returns {object} 429 – Too many verification attempts. * @returns {object} 500 – Server error or Vonage API error. */ app.post('/check-otp', otpCheckLimiter, async (req, res) => { const { phoneNumber, code } = req.body; // Input validation if (!phoneNumber || !code) { return res.status(400).json({ error: 'Phone number and code are required.' }); } // Validate code format (4 or 6 digits) if (!/^[0-9]{4,6}$/.test(code)) { return res.status(400).json({ error: 'Code must be 4-6 digits.' }); } // Format phone number const phoneValidation = validateAndFormatPhone(phoneNumber); if (!phoneValidation.valid) { return res.status(400).json({ error: phoneValidation.error }); } const formattedNumber = phoneValidation.formatted; // Retrieve the verification request const verificationData = verificationRequests[formattedNumber]; if (!verificationData) { console.warn(`⚠️ No pending verification found for: ${formattedNumber}`); return res.status(404).json({ error: 'No pending verification found for this number, or it has expired. Request a new code.' }); } // Check attempt limit (Vonage allows 3 attempts per request) if (verificationData.attempts >= 3) { delete verificationRequests[formattedNumber]; return res.status(429).json({ error: 'Too many failed attempts. Request a new code.' }); } console.log(`🔍 Verifying OTP for: ${formattedNumber} with code: ${code} (Request ID: ${verificationData.requestId})`); try { const result = await vonage.verify.check(verificationData.requestId, code); if (result.status === '0') { // Status '0' indicates successful verification console.log(`✅ Verification successful for: ${formattedNumber}`); // Verification successful – clear the stored request ID delete verificationRequests[formattedNumber]; // In production: Update user status to verified, grant access, etc. res.status(200).json({ message: 'Verification successful!', verified: true }); } else { // Increment attempt counter verificationRequests[formattedNumber].attempts += 1; // Handle Vonage verification failures console.warn(`⚠️ Verification failed for ${formattedNumber}: Status ${result.status} – ${result.error_text}`); // Map error codes to user-friendly messages const errorMessages = { '16': 'Code has expired. Request a new code.', '17': 'Too many incorrect attempts. Request a new code.', '6': 'Invalid code. Try again.', '101': 'No matching verification request found.' }; const userMessage = errorMessages[result.status] || `Verification failed: ${result.error_text}`; const remainingAttempts = 3 - verificationRequests[formattedNumber].attempts; // Clear request if expired or too many attempts if (['16', '17', '101'].includes(result.status)) { delete verificationRequests[formattedNumber]; return res.status(401).json({ error: userMessage }); } res.status(401).json({ error: userMessage, remainingAttempts: remainingAttempts }); } } catch (error) { // Handle errors thrown by the SDK console.error('❌ Server Error checking OTP:', error.message || error); // Check for structured error from Vonage SDK if (error.body && error.body.error_text) { console.error(`❌ Vonage Check API Error Detail: ${error.statusCode} – ${error.body.error_text}`); res.status(error.statusCode || 500).json({ error: `Verification check failed: ${error.body.error_text}` }); } else { // Generic server error res.status(500).json({ error: 'An unexpected server error occurred during verification.' }); } } }); // ... (server start)Key Improvements:
Vonage Verify Check Status Codes:
Source: Vonage Verify API Status Codes
3. Building a Complete API Layer
You've now defined the core API endpoints with security enhancements:
GET /health: Health check endpoint for monitoring.POST /request-otp: Initiates the verification process (rate limited: 5 per 15 min per IP).POST /check-otp: Verifies the submitted code (rate limited: 10 per 5 min per IP).Vonage Verify Workflow Configuration:
The Vonage Verify API supports 7 predefined workflows for delivering OTP codes (source):
Recommendation: Use
workflow_id: 6(SMS-only) to avoid unexpected voice calls and associated costs. The default workflow (1) will attempt voice calls after 125 seconds if the code isn't verified.Security Considerations – HTTPS Requirement:
All production deployments must use HTTPS to prevent:
Use services like Let's Encrypt for free SSL/TLS certificates, or deploy behind a reverse proxy (nginx, Cloudflare) that handles TLS termination.
API Documentation & Testing Examples:
Here are examples using
curlto test the endpoints. Replace placeholders accordingly.3.1 Test Health Check:
Expected Response:
{ "status": "ok", "service": "vonage-otp-api" }3.2 Test Requesting an OTP:
(Replace
+14155552671with a real phone number you can receive SMS on, in E.164 format.)curl -X POST http://localhost:3000/request-otp \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155552671"}'Expected Success Response (200 OK):
{ "message": "OTP requested successfully. Check your phone.", "expiresIn": 300 }(You should receive an SMS on the target phone within seconds.)
Example Error Response (400 Bad Request – Invalid format):
curl -X POST http://localhost:3000/request-otp \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "555-1234"}'{ "error": "Invalid phone number format" }3.3 Test Checking an OTP:
(Use the same phone number as above and the code received via SMS.)
curl -X POST http://localhost:3000/check-otp \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155552671", "code": "123456"}' # Replace with actual codeExpected Success Response (200 OK):
{ "message": "Verification successful!", "verified": true }Example Error Response (401 Unauthorized – Wrong Code):
{ "error": "Invalid code. Try again.", "remainingAttempts": 2 }Example Error Response (429 Too Many Requests):
{ "error": "Too many OTP requests from this IP. Try again after 15 minutes." }4. Security Considerations
Critical Security Requirements:
HTTPS Only: All production deployments must use HTTPS. Never transmit OTP codes or credentials over HTTP.
Rate Limiting: Implemented via
express-rate-limit:/request-otp: Max 5 requests per 15 minutes per IP/check-otp: Max 10 attempts per 5 minutes per IPInput Validation & Sanitization:
libphonenumber-jsbefore API callsAttempt Limiting:
Session Management: (Production requirement)
request_idwith authenticated user session, not just phone numberhttpOnly,secure, andsameSiteflagsexpress-session:const session = require('express-session'); app.use(session({ secret: process.env.SESSION_SECRET, // Strong random secret resave: false, saveUninitialized: false, cookie: { secure: true, // HTTPS only httpOnly: true, // Prevent XSS access sameSite: 'strict', // CSRF protection maxAge: 3600000 // 1 hour } }));Data Privacy & GDPR Compliance:
+1415555****Prevent Replay Attacks:
request_idcan only be verified once (Vonage enforces)Secret Management:
.envfiles to version controlOWASP Recommendations for MFA:
5. Database Schema and Data Layer (Production)
For production, replace the in-memory
verificationRequestsobject with a persistent store.Option 1: Redis (Recommended for OTP storage)
Redis provides fast key-value storage with built-in expiration, ideal for temporary verification data.
const redis = require('redis'); const client = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' }); await client.connect(); // Store verification request await client.setEx( `verify:${userId}`, // Key: verify:<user_id> 600, // TTL: 10 minutes JSON.stringify({ requestId: result.request_id, phoneNumber: formattedNumber, attempts: 0, createdAt: new Date().toISOString() }) ); // Retrieve verification request const verifyData = await client.get(`verify:${userId}`); const data = JSON.parse(verifyData); // Delete after successful verification await client.del(`verify:${userId}`);Option 2: PostgreSQL Database Schema
For applications with existing user databases:
-- Users table with 2FA fields CREATE TABLE users ( user_id SERIAL PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, phone_number VARCHAR(20), -- Verified phone in E.164 phone_verified BOOLEAN DEFAULT FALSE, two_factor_enabled BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Separate table for verification requests CREATE TABLE verification_requests ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE, request_id VARCHAR(50) NOT NULL, -- Vonage request_id phone_number VARCHAR(20) NOT NULL, attempts INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'pending', -- pending, verified, expired, failed expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), verified_at TIMESTAMPTZ, -- Performance indexes INDEX idx_user_id (user_id), INDEX idx_request_id (request_id), INDEX idx_expires_at (expires_at) ); -- Auto-delete expired records (PostgreSQL with pg_cron extension) CREATE EXTENSION IF NOT EXISTS pg_cron; SELECT cron.schedule( 'delete-expired-verifications', '0 * * * *', -- Every hour 'DELETE FROM verification_requests WHERE expires_at < NOW()' );Integration Example with Database:
// Using pg (PostgreSQL client) const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); // Store verification request app.post('/request-otp', otpRequestLimiter, async (req, res) => { // ... validation code ... const result = await vonage.verify.start({ /* ... */ }); if (result.status === '0') { // Store in database instead of memory await pool.query( `INSERT INTO verification_requests (user_id, request_id, phone_number, expires_at) VALUES ($1, $2, $3, NOW() + INTERVAL '10 minutes')`, [req.session.userId, result.request_id, formattedNumber] ); res.status(200).json({ message: 'OTP sent successfully' }); } }); // Verify OTP app.post('/check-otp', otpCheckLimiter, async (req, res) => { // ... validation code ... // Retrieve from database const { rows } = await pool.query( `SELECT request_id, attempts, expires_at FROM verification_requests WHERE user_id = $1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1`, [req.session.userId] ); if (rows.length === 0) { return res.status(404).json({ error: 'No pending verification' }); } const verifyData = rows[0]; // Check expiration if (new Date() > new Date(verifyData.expires_at)) { await pool.query( `UPDATE verification_requests SET status = 'expired' WHERE user_id = $1`, [req.session.userId] ); return res.status(401).json({ error: 'Code expired' }); } // Check attempts if (verifyData.attempts >= 3) { await pool.query( `UPDATE verification_requests SET status = 'failed' WHERE user_id = $1`, [req.session.userId] ); return res.status(429).json({ error: 'Too many attempts' }); } // Verify with Vonage const result = await vonage.verify.check(verifyData.request_id, code); if (result.status === '0') { // Success – update database await pool.query( `UPDATE verification_requests SET status = 'verified', verified_at = NOW() WHERE user_id = $1`, [req.session.userId] ); // Update user's phone_verified status await pool.query( `UPDATE users SET phone_verified = TRUE, two_factor_enabled = TRUE WHERE user_id = $1`, [req.session.userId] ); res.status(200).json({ message: 'Verification successful' }); } else { // Increment attempts await pool.query( `UPDATE verification_requests SET attempts = attempts + 1 WHERE user_id = $1`, [req.session.userId] ); res.status(401).json({ error: 'Invalid code', remainingAttempts: 3 - (verifyData.attempts + 1) }); } });6. Troubleshooting and Common Issues
Issue: SMS Not Received
Possible causes:
Issue: "Throttled" Error (Status 1)
Cause: Exceeding 30 requests/second to Vonage API (source).
Solution: Implement request queuing or reduce request rate.
Issue: "Invalid credentials" (Status 4)
Causes:
VONAGE_API_KEYorVONAGE_API_SECRETin.envdotenv.config()is called first)Solution: Verify credentials on Vonage Dashboard.
Issue: Code Expired (Status 16)
Default expiration: 300 seconds (5 minutes) for workflow 6.
Solution: Request a new code. Consider increasing
pin_expiryparameter up to 3600s (1 hour).Issue: Rate Limit Errors (429)
Cause: Application-level rate limiting triggered (5 requests per 15 min).
Solution: Wait for the cooldown period or adjust rate limit configuration for your use case.
International SMS Delivery Challenges:
Consult Vonage country-specific guides for requirements.
Debugging Tips:
Enable detailed logging:
console.log('Vonage request:', JSON.stringify({ number, brand, workflow_id })); console.log('Vonage response:', JSON.stringify(result));Test with a known working number first.
Check Vonage API logs on the dashboard for detailed error messages.
Verify environment variables are loaded:
console.log('API Key loaded:', !!process.env.VONAGE_API_KEY);7. Next Steps and Production Deployment
Production Readiness Checklist:
Docker Deployment Example:
Docker Compose with Redis:
version: '3.8' services: api: build: . ports: - "3000:3000" environment: - NODE_ENV=production - VONAGE_API_KEY=${VONAGE_API_KEY} - VONAGE_API_SECRET=${VONAGE_API_SECRET} - REDIS_URL=redis://redis:6379 depends_on: - redis restart: unless-stopped redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis-data:/data restart: unless-stopped volumes: redis-data:Advanced Features to Consider:
Additional Resources:
Conclusion:
This guide provides a comprehensive implementation of SMS-based OTP 2FA using Node.js, Express, and the Vonage Verify API. The implementation includes essential security features like rate limiting, input validation, and proper error handling. For production use, migrate from in-memory storage to Redis or a database, implement proper user session management, deploy over HTTPS, and consider the advanced security features discussed.
While SMS 2FA is a significant security improvement over password-only authentication, consider offering users the option to upgrade to TOTP-based authenticators or hardware security keys for enhanced protection against SIM-swap and phishing attacks.