Implementing SMS OTP Verification in Node.js with Express and Vonage
A step-by-step guide to building an SMS OTP verification system using Node.js, Express, and the Vonage Verify API, covering setup, API implementation, security, and deployment.
Two-factor authentication (2FA), often implemented via One-Time Passwords (OTPs) sent over SMS, adds a critical layer of security to user accounts and transactions. It verifies user identity by requiring something they know (password) and something they have (access to their phone).
This guide provides a step-by-step walkthrough for building a secure and robust SMS OTP verification system using Node.js, Express, and the Vonage Verify API. We will create simple API endpoints capable of requesting an OTP code sent to a user's phone number and verifying the code entered by the user.
Project Goals:
Build an Express application with two core API endpoints:
/request-otp: Accepts a phone number and initiates an OTP verification request via Vonage.
/verify-otp: Accepts a Vonage request ID and the user-submitted OTP code to verify the code's validity.
Securely handle Vonage API credentials.
Implement basic input validation and error handling.
Provide a foundation for integrating SMS OTP into larger authentication systems.
Technology Stack:
Node.js: A JavaScript runtime environment for building server-side applications.
Express: A minimal and flexible Node.js web application framework.
Vonage Verify API: A service that handles the generation and delivery of OTPs via SMS (and voice fallback) and manages the verification process.
@vonage/server-sdk: The official Vonage Server SDK for Node.js to interact with the Vonage API.
dotenv: A module to load environment variables from a .env file into process.env.
body-parser: Node.js body parsing middleware (included directly in Express v4.16.0+, but explicit usage can enhance clarity for all versions).
System Architecture:
The flow involves the following components:
Client Application (e.g., Web Frontend, Mobile App): Initiates the OTP request with a phone number and later submits the received code for verification. (Not built in this guide, but interacts with our API).
Node.js/Express API Server (This Guide):
Receives requests from the client.
Uses the Vonage SDK to interact with the Vonage Verify API.
Sends OTP requests to Vonage.
Sends verification checks to Vonage.
Returns responses (success/failure, request ID) to the client.
Vonage Verify API:
Receives requests from our API server.
Generates and sends the OTP SMS to the user's phone.
Handles OTP lifecycle (expiry, retries via different channels if configured).
Validates submitted codes against the specific request.
Node.js and npm (or yarn): Installed on your development machine. Download Node.js here.
Vonage API Account: Sign up for a free Vonage account.
Vonage API Key and Secret: Find these at the top of your Vonage API Dashboard after signing up.
Vonage Brand Name: You'll use this in the SMS message (e.g., ""YourApp""). This can often be set within the Vonage dashboard or passed via the API. See Section 3 and Section 7 for notes on configuration and potential restrictions.
(Optional but Recommended) Vonage Virtual Number: While the Verify API can often use an alphanumeric sender ID or a shared number pool, having a dedicated Vonage number can be useful for consistency and testing. You can purchase one via Numbers > Buy Numbers in the dashboard.
1. Setting up the project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir vonage-otp-app
cd vonage-otp-app
Initialize Node.js Project:
Create a package.json file to manage project dependencies and scripts.
Bash
npm init -y
Install Dependencies:
Install Express, the Vonage Server SDK, dotenv for environment variable management, and body-parser for handling request bodies.
@vonage/server-sdk: To interact with the Vonage APIs.
dotenv: To load environment variables from a .env file.
body-parser: To parse incoming JSON request bodies.
Create .env File:
Create a file named .env in the root of your project. This file will store your sensitive API credentials and configuration. Never commit this file to version control.
.env
# .env
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_BRAND_NAME=""Your App Name"" # Replace with your app's name
PORT=3000 # Optional: Define the port the server runs on
Replace YOUR_VONAGE_API_KEY and YOUR_VONAGE_API_SECRET with the actual credentials from your Vonage dashboard. Set VONAGE_BRAND_NAME to the name users will see in the SMS message (e.g., ""MyApp Security"").
Create .gitignore File:
Create a .gitignore file to prevent sensitive files and unnecessary directories from being committed to Git.
Create index.js:
Create the main application file, index.js, in the project root.
Bash
touch index.js
Basic Server Setup:
Add the following initial setup code to index.js:
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');
// --- Input Validation ---// Basic validation: Check if a string is a plausible phone number (E.164 format is ideal)// Production apps need more robust validation (e.g., using libraries like libphonenumber-js)constisValidPhoneNumber = (phoneNumber) => {
// Very basic check: starts with '+', contains digits, reasonable lengthconst phoneRegex = /^\+[1-9]\d{1,14}$/;
returntypeof phoneNumber === 'string' && phoneRegex.test(phoneNumber);
};
// Basic validation: Check if the code is a string of 4 to 6 digitsconstisValidOtpCode = (code) => {
const codeRegex = /^\d{4,6}$/;
returntypeof code === 'string' && codeRegex.test(code);
};
// --- Vonage Initialization ---// Ensure API key and secret are loadedif (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('Error: VONAGE_API_KEY or VONAGE_API_SECRET is not set in the environment variables.');
process.exit(1); // Exit if credentials are missing
}
const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// --- Express App Setup ---const app = express();
const port = process.env.PORT || 3000;
// Middleware to parse JSON request bodies (included in Express 4.16.0+, but explicit use is clear)
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// --- API Endpoints (To be added in the next section) ---// --- Simple Health Check Endpoint ---
app.get('/health', (req, res) => {
res.status(200).send('OK');
});
// --- Start Server ---
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
This code:
Loads environment variables using dotenv.
Imports necessary modules.
Includes basic placeholder functions for phone number and OTP code validation (emphasizing that production requires more robust checks).
Initializes the Vonage SDK with credentials from .env. Includes a check to ensure credentials are set.
Sets up the Express application and middleware.
Adds a basic /health endpoint.
Starts the server.
2. Implementing Core Functionality & API Layer
Now, let's build the two main API endpoints for requesting and verifying OTPs.
Requesting an OTP
This endpoint will receive a phone number, trigger the Vonage Verify process, and return the request_id needed for the verification step.
Add the /request-otp Endpoint:
Add the following route handler to your index.js file, typically placed before the /health endpoint or server start logic.
JavaScript
// index.js// ... (previous setup code) ...// --- API Endpoints ---// POST /request-otp// Initiates an OTP verification request
app.post('/request-otp', async (req, res) => {
const { phoneNumber } = req.body;
// 1. Input Validationif (!isValidPhoneNumber(phoneNumber)) {
console.warn(`Invalid phone number format received: ${phoneNumber}`);
return res.status(400).json({ success: false, error: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' });
}
const brand = process.env.VONAGE_BRAND_NAME || ""MyApp""; // Fallback brand nameconsole.log(`Requesting OTP for phone number: ${phoneNumber} with brand: ${brand}`);
try {
// 2. Call Vonage Verify APIconst result = await vonage.verify.start({
number: phoneNumber,
brand: brand,
// code_length: '6' // Optional: Specify 6 digits (default is 4)// pin_expiry: 300 // Optional: Set expiry time in seconds (default 300 = 5 minutes)// workflow_id: 6 // Optional: Specify SMS -> TTS -> TTS workflow
});
// 3. Handle Vonage Responseif (result.status === '0') {
// Success - Status '0' indicates the request was acceptedconsole.log(`OTP request successful for ${phoneNumber}. Request ID: ${result.request_id}`);
return res.status(200).json({ success: true, requestId: result.request_id });
} else {
// Vonage returned an error statusconsole.error(`Vonage Verify API error for ${phoneNumber}. Status: ${result.status}, Error: ${result.error_text}`);
// Map common Vonage error statuses to user-friendly messages or appropriate HTTP status codeslet statusCode = 500;
let errorMessage = result.error_text || 'Failed to send OTP.';
if (result.status === '3') { // Invalid number format
statusCode = 400;
errorMessage = 'Invalid phone number format provided to Vonage.';
} elseif (result.status === '10') { // Concurrent verifications to the same number
statusCode = 429; // Too Many Requests
errorMessage = 'Concurrent verification attempts detected. Please wait before trying again.';
} elseif (result.status === '9') { // Partner quota exceeded
statusCode = 503; // Service Unavailable
errorMessage = 'Verification service quota exceeded. Please try again later.';
}
return res.status(statusCode).json({ success: false, error: errorMessage, vonage_status: result.status });
}
} catch (error) {
// 4. Handle SDK/Network Errorsconsole.error(`Error requesting OTP for ${phoneNumber}:`, error);
return res.status(500).json({ success: false, error: 'An unexpected error occurred while requesting the OTP.' });
}
});
// ... (rest of the code: /verify-otp, /health, app.listen) ...
Explanation:
It extracts the phoneNumber from the JSON request body.
Input Validation: It uses the basic isValidPhoneNumber check. Returns a 400 Bad Request if invalid. Remember: Use a robust library like google-libphonenumber via a Node.js wrapper (e.g., libphonenumber-js) in production for proper E.164 validation and formatting.
It retrieves the brand name from environment variables.
It calls vonage.verify.start() with the phone number and brand. Optional parameters like code_length, pin_expiry, and workflow_id are commented out but show how to customize the process.
Vonage Response Handling:
If result.status is '0', the request was successful. It returns a 200 OK response with success: true and the crucial requestId. The client must store this requestId to use in the verification step.
If result.status is non-zero, an error occurred at the Vonage API level. We log the details and map common statuses (like '3' for invalid number, '10' for concurrent requests) to appropriate HTTP status codes (400, 429) and user-friendly error messages. A generic 500 error is returned for unmapped or unexpected Vonage errors.
SDK/Network Error Handling: A try...catch block handles potential errors during the SDK call itself (e.g., network issues, invalid credentials setup). It returns a 500 Internal Server Error.
Verifying the OTP
This endpoint receives the request_id (obtained from the previous step) and the OTP code entered by the user. It asks Vonage to check if the code is correct for that specific request.
Add the /verify-otp Endpoint:
Add the following route handler to index.js, after the /request-otp endpoint.
JavaScript
// index.js// ... (previous setup code including /request-otp) ...// POST /verify-otp// Verifies the OTP code submitted by the user
app.post('/verify-otp', async (req, res) => {
const { requestId, code } = req.body;
// 1. Input Validationif (!requestId || typeof requestId !== 'string' || requestId.trim() === '') {
return res.status(400).json({ success: false, error: 'Request ID is required.' });
}
if (!isValidOtpCode(code)) {
console.warn(`Invalid OTP code format received for request ID ${requestId}: ${code}`);
return res.status(400).json({ success: false, error: 'Invalid OTP code format. Must be 4-6 digits.' });
}
console.log(`Verifying OTP for request ID: ${requestId} with code: ${code}`);
try {
// 2. Call Vonage Verify Check APIconst result = await vonage.verify.check(requestId, code);
// 3. Handle Vonage Responseif (result.status === '0') {
// Success - Status '0' means the code was correctconsole.log(`OTP verification successful for request ID: ${requestId}`);
return res.status(200).json({ success: true, message: 'OTP verified successfully.' });
} else {
// Vonage returned an error status (e.g., wrong code, expired request)console.warn(`Vonage Verify Check API error for request ID ${requestId}. Status: ${result.status}, Error: ${result.error_text}`);
let statusCode = 401; // Unauthorized (or Bad Request 400) is often suitable for failed verificationlet errorMessage = result.error_text || 'OTP verification failed.';
if (result.status === '16') { // The code provided was incorrect
errorMessage = 'Incorrect OTP code provided.';
} elseif (result.status === '6') { // The verify request associated with this request_id has expired
errorMessage = 'OTP request has expired. Please request a new code.';
statusCode = 410; // Gone (or 400 Bad Request)
} elseif (result.status === '17') { // A wrong code was provided too many times
errorMessage = 'Too many incorrect attempts. Please request a new code.';
statusCode = 429; // Too Many Requests
} elseif (result.status === '5') { // Invalid request ID format
errorMessage = 'Internal error: Invalid request ID format.'; // More likely a server issue
statusCode = 500;
}
return res.status(statusCode).json({ success: false, error: errorMessage, vonage_status: result.status });
}
} catch (error) {
// 4. Handle SDK/Network Errorsconsole.error(`Error verifying OTP for request ID ${requestId}:`, error);
// Check if the error indicates the request ID doesn't exist// Note: The exact error structure (like status '101') can vary based on SDK updates or Vonage API changes.// Always consult the official Vonage documentation and test error responses.// This is an example check:if (error.body && error.body.status === '101' && error.body.error_text === 'No matching request found') {
console.warn(`Attempted to verify non-existent or already verified request ID: ${requestId}`);
return res.status(404).json({ success: false, error: 'Verification request not found or already completed.' });
}
return res.status(500).json({ success: false, error: 'An unexpected error occurred during OTP verification.' });
}
});
// ... (/health, app.listen) ...
Explanation:
It extracts requestId and code from the request body.
Input Validation: Checks if both requestId and code are present and use the basic isValidOtpCode check. Returns 400 Bad Request if invalid.
It calls vonage.verify.check() with the requestId and code.
Vonage Response Handling:
If result.status is '0', the code was correct. It returns 200 OK with success: true.
If non-zero, the code was incorrect, the request expired, or another error occurred. We log the details and map common statuses ('16' for wrong code, '6' for expired, '17' for too many attempts) to appropriate HTTP status codes (often 401 Unauthorized or 400 Bad Request, but 410 Gone for expired or 429 for rate-limited attempts can be more specific) and user-friendly messages.
SDK/Network Error Handling: The try...catch handles SDK or network errors. It includes an example check for a specific error structure (like status '101' - No matching request found) which might indicate the requestId was invalid, already used, or expired, returning a 404 Not Found in that case. This specific check might need adjustment based on observed errors from the SDK. Otherwise, it returns a 500 Internal Server Error.
3. Integrating with Vonage (Configuration Details)
Properly configuring the Vonage integration involves obtaining credentials and understanding the environment variables used.
Your API Key and API Secret are displayed prominently on the main dashboard page.
Copy these values carefully.
Set Environment Variables:
Open the .env file you created earlier.
Paste your API Key as the value for VONAGE_API_KEY.
Paste your API Secret as the value for VONAGE_API_SECRET.
Set VONAGE_BRAND_NAME to the name you want displayed in the SMS message (e.g., VONAGE_BRAND_NAME=""TechCorp Security""). This helps users identify the source of the OTP. Note that Vonage may have character limits or other restrictions on brand names depending on the destination country and regulations; consult the Vonage documentation for details. Also, see the note on Alphanumeric Sender IDs in Section 7.
Security: Remember, the .env file contains sensitive credentials.
Ensure it's listed in your .gitignore file.
Do not commit it to version control (GitHub, GitLab, etc.).
Use secure methods for managing environment variables in production environments (e.g., platform-specific configuration services like AWS Secrets Manager, Azure Key Vault, or system environment variables).
4. Error Handling and Logging
We've already incorporated basic error handling, but let's refine the strategy.
Consistent Error Responses: Our endpoints return JSON objects with a success flag (boolean) and either a relevant payload (like requestId) on success or an error message (string) on failure. Including the vonage_status in error responses can aid debugging.
HTTP Status Codes: Use appropriate HTTP status codes to signal the nature of the error (400 for bad input, 401/403 for auth failures, 404 for not found, 429 for rate limits, 500 for server errors, 503 for service unavailable).
Logging:
We use console.log for informational messages (request received, success) and console.warn or console.error for issues.
Production Logging: For production, use a dedicated logging library like winston or pino. These offer features like:
Different log levels (debug, info, warn, error).
Structured logging (JSON format) for easier parsing by log analysis tools.
Log rotation and multiple transport options (file, console, external services).
Log Sensitivity: Be careful not to log sensitive user data like the OTP code itself, unless strictly necessary for debugging under controlled conditions. Log requestId and phone number (potentially masked) for correlation.
Example of enhancing logging (conceptual):
JavaScript
// Conceptual enhancement with a logger library (e.g., Winston)// const winston = require('winston');// const logger = winston.createLogger({ /* ... configuration ... */ });// Inside /request-otp error handler:// logger.error(`Vonage Verify API error for ${phoneNumber}. Status: ${result.status}, Error: ${result.error_text}`, { phoneNumber, vonageStatus: result.status });// Inside /verify-otp catch block:// logger.error(`Error verifying OTP for request ID ${requestId}:`, { error, requestId });
5. Security Features
Implementing OTP is a security feature itself, but the implementation needs protection.
Input Validation:
Phone Numbers: As mentioned, use a robust library (e.g., libphonenumber-js) to validate and potentially format phone numbers into the E.164 standard (+14155552671) before sending them to Vonage. This prevents errors and potential injection issues.
OTP Codes: Validate that the code format matches expectations (e.g., 4-6 digits).
Request IDs: Validate that requestId looks like a valid identifier (though Vonage handles the actual validation).
Secure Credential Storage: Use environment variables and secure management practices, never hardcode secrets.
Rate Limiting: This is critical for OTP endpoints:
Prevents Abuse: Stops attackers from spamming users with OTP messages (toll fraud).
Brute-Force Protection: Limits how many times a user (or attacker) can attempt to verify a code for a given requestId or from a specific IP address.
Implementation: Use middleware like express-rate-limit. Apply separate limits for requesting OTPs (e.g., 5 requests per phone number per hour) and verifying codes (e.g., 5 attempts per requestId, 10 attempts per IP per minute).
// index.js - Add near the top after `app = express()`const rateLimit = require('express-rate-limit');
// Rate limiter for requesting OTPs (e.g., per IP)const requestOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 10, // Limit each IP to 10 OTP requests per windowMsmessage: { success: false, error: 'Too many OTP requests created from this IP, please try again after 15 minutes' },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Rate limiter for verifying OTPs (e.g., per IP)const verifyOtpLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutesmax: 5, // Limit each IP to 5 verification attempts per windowMs (adjust based on Vonage's attempt limits)message: { success: false, error: 'Too many verification attempts from this IP, please try again after 5 minutes' },
standardHeaders: true,
legacyHeaders: false,
});
// Apply limiters to specific routes
app.use('/request-otp', requestOtpLimiter);
app.use('/verify-otp', verifyOtpLimiter);
// ... (rest of the app setup and routes)
Note: IP-based limiting is basic. More sophisticated limiting might involve user accounts or phone numbers, requiring session management or a database.
HTTPS: Always serve your application over HTTPS in production to encrypt communication between the client and your server. Use a reverse proxy like Nginx or Caddy, or platform services (Heroku, AWS Load Balancer) to handle TLS termination.
6. Database Schema and Data Layer (Considerations)
While this guide doesn't implement a database, a real-world application almost certainly needs one:
Storing request_id: You need to associate the requestId returned by /request-otp with the user's session or account attempting verification. When the user submits the code to /verify-otp, you retrieve the correct requestId from their session/database record.
Tracking Verification Status: Store whether a user's phone number has been successfully verified.
Rate Limiting by User/Phone: Implement more granular rate limiting based on user ID or phone number stored in the database, rather than just IP address.
Audit Trails: Log verification attempts (success/failure, timestamp, user ID, IP address) for security monitoring.
Example Schema Snippets (Conceptual - using Prisma as an example):
Prisma
// schema.prisma (Conceptual Example)
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
phoneNumber String? @unique
phoneVerified Boolean @default(false)
// ... other fields
otpRequests OtpRequest[]
}
model OtpRequest {
id String @id @default(cuid())
vonageRequestId String @unique // The ID from Vonage
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime // Calculate based on Vonage pin_expiry
createdAt DateTime @default(now())
verifiedAt DateTime?
attempts Int @default(0) // Track verification attempts for this request
}
This requires setting up a database, an ORM like Prisma or Sequelize, and integrating data access logic into your route handlers.
7. Troubleshooting and Caveats
Invalid API Credentials: Ensure VONAGE_API_KEY and VONAGE_API_SECRET in your .env file are correct and have no extra spaces. Error messages often include ""authentication failed"" or similar.
Incorrect Phone Number Format: Vonage Verify generally expects E.164 format (+14155552671). Using local formats might work for some regions but can be unreliable. Use a library for robust validation/formatting. Vonage status 3 often indicates this.
Concurrent Verifications: Vonage typically prevents sending multiple OTPs to the same number within a short period (e.g., 30 seconds). This results in Vonage status 10. Implement logic to prevent users from rapidly clicking ""Resend Code"" or handle this error gracefully. Consider adding cancellation logic if needed (using vonage.verify.cancel(REQUEST_ID)).
Expired Request ID: OTP codes expire (default 5 minutes). Trying to verify an expired code results in Vonage status 6. Inform the user the code expired and prompt them to request a new one.
Incorrect Code Entered: Results in Vonage status 16. After too many attempts (Vonage default might be 5), status 17 occurs. Implement attempt tracking on your side or rely on Vonage's limits, informing the user clearly.
Rate Limits Hit: If you implement express-rate-limit or hit Vonage's own internal limits, requests will fail (often with HTTP 429). Ensure your limits are reasonable. Vonage status 9 indicates hitting their platform quota.
Non-Existent Request ID: Trying to verify a requestId that was never generated, already verified, or cancelled might result in Vonage status 101 (No matching request found - often surfaced as an SDK error rather than a non-zero status in the check result) or potentially status 5. Return a 404 or 400 error.
Vonage Service Issues: Occasionally, the Vonage API might experience downtime or delays. Implement appropriate timeouts and consider fallback mechanisms if critical. Monitor Vonage's status page.
Alphanumeric Sender ID (VONAGE_BRAND_NAME Behavior): Using a brand name as the sender ID depends heavily on carrier support and regulations in the destination country. In some regions (like the US), carriers might replace the alphanumeric sender ID with a shared short code or long code number pool managed by Vonage for compliance reasons. This means the VONAGE_BRAND_NAME you set might not always appear as the sender. Check Vonage documentation for country-specific sender ID behavior and potential registration requirements for Alphanumeric Sender IDs where supported.
8. Deployment and CI/CD
Environment Variables: Use your deployment platform's mechanism for setting environment variables (e.g., Heroku Config Vars, AWS Parameter Store, Docker environment files). Do not commit .env files to your production repository.
Build Process: If using TypeScript or a build step, ensure your Dockerfile or deployment script includes compiling the code. For plain JavaScript, usually just need to copy files and install production dependencies (npm install --omit=dev).
PORT Binding: Ensure your application listens on the port specified by the environment (often process.env.PORT), not a hardcoded port like 3000. Our code already does this (process.env.PORT || 3000).
HTTPS: Configure HTTPS termination via a load balancer or reverse proxy.
CI/CD Pipeline (Conceptual):
Trigger: On push to main branch or tag creation.
Checkout Code: Get the latest source.
Install Dependencies:npm ci (uses package-lock.json for deterministic installs).
(Optional) Linting/Testing: Run eslint, npm test.
(Optional) Build:npm run build (if applicable).
Package: Create a Docker image or deployment artifact.
Deploy: Push the image to a registry and deploy to your platform (Heroku, AWS ECS, Kubernetes, etc.).
Smoke Tests: Run basic checks against the deployed application (e.g., hit the /health endpoint).
Rollback: Have a plan to quickly revert to a previous working version if a deployment fails.
Example Dockerfile (Simple):
Dockerfile
# Dockerfile
# Use an official Node.js runtime as a parent image
# Using 'slim' variant for smaller image size
FROM node:18-slim
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json (or yarn.lock)
# Copying these first leverages Docker layer caching
COPY package*.json ./
# Install only production dependencies using 'npm ci'
# Use 'npm ci' for faster, more reliable installs in CI/CD environments
# It requires a package-lock.json or npm-shrinkwrap.json
RUN npm ci --omit=dev
# Bundle app source
COPY . .
# Your app binds to port 3000, but PORT env var can override it
# Expose the port the app runs on (useful for documentation, less critical for runtime)
EXPOSE 3000
# Define the command to run your app
CMD [ ""node"", ""index.js"" ]
Frequently asked questions
Implement 2FA by integrating the Vonage Verify API into a Node.js and Express application. Create two API endpoints: /request-otp to send the OTP and /verify-otp to verify it against the Vonage service. Use environment variables for your Vonage API credentials and protect your .env file from being checked into version control.
The Vonage Verify API is a service that handles the generation, delivery via SMS (and potential voice fallback), and verification process of One-Time Passwords (OTPs). It simplifies the implementation of 2FA in applications.
Carriers in some regions might replace alphanumeric sender IDs for compliance. The sender ID you set via VONAGE_BRAND_NAME may be replaced by a short code or long code managed by Vonage. Check Vonage's documentation for specifics on your region.
While the Verify API can often use an alphanumeric sender ID or shared number pool, having a dedicated Vonage number is useful for consistency and reliable testing. It also can allow for better user experience when the brand name is not available or not permitted.
Yes, the Vonage Verify API allows customization. Parameters like code_length and pin_expiry can be set in the vonage.verify.start() function to control OTP length and expiry time.
Handle errors by checking the status property in the Vonage API response. Common errors include incorrect phone number format (status = 3), concurrent verifications (status = 10), and expired request IDs (status = 6). Map these to appropriate HTTP status codes and user-friendly messages in your API responses.
The requestId is a unique identifier returned by Vonage after initiating an OTP request via vonage.verify.start(). This ID is essential for verifying the OTP later; it links the user-entered code to the correct verification request.
Store credentials in a .env file, load them using the dotenv module, and include this file in your .gitignore to prevent it from being committed to version control. For production, use more secure methods like platform-specific secret management services.
Use a dedicated logging library like winston or pino for production, categorizing logs by levels (info, warn, error). Log requestId, potentially masked phone numbers, and timestamps for correlation. Avoid logging sensitive data like the OTP itself.
Use rate limiting middleware like express-rate-limit to restrict requests per IP, user account, or phone number. This protects against brute-force attacks and prevents excessive OTP messages, mitigating abuse and potential toll fraud.
You'll need to store the Vonage requestId, user association, verification status, expiry time, and potentially attempt counts. An OtpRequest table linked to your User table is a good starting point, ensuring the vonageRequestId is unique.
Input validation ensures data integrity and security. Validate phone numbers using libraries like libphonenumber-js, check OTP code format (4-6 digits), and sanitize requestId before sending to Vonage, preventing issues and potential injection vulnerabilities.
This Vonage error (often seen in SDK responses with error.body.status === '101') usually means the requestId is invalid, already verified, canceled, or expired. Check your server-side storage and ensure you are providing a valid and current requestId.
Two-factor authentication (2FA), often implemented via One-Time Passwords (OTPs) sent over SMS, adds a critical layer of security to user accounts and transactions. It verifies user identity by requiring something they know (password) and something they have (access to their phone).
This guide provides a step-by-step walkthrough for building a secure and robust SMS OTP verification system using Node.js, Express, and the Vonage Verify API. We will create simple API endpoints capable of requesting an OTP code sent to a user's phone number and verifying the code entered by the user.
Project Goals:
/request-otp: Accepts a phone number and initiates an OTP verification request via Vonage./verify-otp: Accepts a Vonage request ID and the user-submitted OTP code to verify the code's validity.Technology Stack:
@vonage/server-sdk: The official Vonage Server SDK for Node.js to interact with the Vonage API.dotenv: A module to load environment variables from a.envfile intoprocess.env.body-parser: Node.js body parsing middleware (included directly in Express v4.16.0+, but explicit usage can enhance clarity for all versions).System Architecture:
The flow involves the following components:
A simplified interaction diagram:
Prerequisites:
""YourApp""). This can often be set within the Vonage dashboard or passed via the API. See Section 3 and Section 7 for notes on configuration and potential restrictions.1. Setting up the project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-otp-app cd vonage-otp-appInitialize Node.js Project: Create a
package.jsonfile to manage project dependencies and scripts.Install Dependencies: Install Express, the Vonage Server SDK,
dotenvfor environment variable management, andbody-parserfor handling request bodies.express: The web framework.@vonage/server-sdk: To interact with the Vonage APIs.dotenv: To load environment variables from a.envfile.body-parser: To parse incoming JSON request bodies.Create
.envFile: Create a file named.envin the root of your project. This file will store your sensitive API credentials and configuration. Never commit this file to version control.Replace
YOUR_VONAGE_API_KEYandYOUR_VONAGE_API_SECRETwith the actual credentials from your Vonage dashboard. SetVONAGE_BRAND_NAMEto the name users will see in the SMS message (e.g.,""MyApp Security"").Create
.gitignoreFile: Create a.gitignorefile to prevent sensitive files and unnecessary directories from being committed to Git.Create
index.js: Create the main application file,index.js, in the project root.touch index.jsBasic Server Setup: Add the following initial setup code to
index.js:// 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'); // --- Input Validation --- // Basic validation: Check if a string is a plausible phone number (E.164 format is ideal) // Production apps need more robust validation (e.g., using libraries like libphonenumber-js) const isValidPhoneNumber = (phoneNumber) => { // Very basic check: starts with '+', contains digits, reasonable length const phoneRegex = /^\+[1-9]\d{1,14}$/; return typeof phoneNumber === 'string' && phoneRegex.test(phoneNumber); }; // Basic validation: Check if the code is a string of 4 to 6 digits const isValidOtpCode = (code) => { const codeRegex = /^\d{4,6}$/; return typeof code === 'string' && codeRegex.test(code); }; // --- Vonage 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 or VONAGE_API_SECRET is not set in the environment variables.'); process.exit(1); // Exit if credentials are missing } const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // --- Express App Setup --- const app = express(); const port = process.env.PORT || 3000; // Middleware to parse JSON request bodies (included in Express 4.16.0+, but explicit use is clear) app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); // --- API Endpoints (To be added in the next section) --- // --- Simple Health Check Endpoint --- app.get('/health', (req, res) => { res.status(200).send('OK'); }); // --- Start Server --- app.listen(port, () => { console.log(`Server listening on port ${port}`); });This code:
dotenv..env. Includes a check to ensure credentials are set./healthendpoint.2. Implementing Core Functionality & API Layer
Now, let's build the two main API endpoints for requesting and verifying OTPs.
Requesting an OTP
This endpoint will receive a phone number, trigger the Vonage Verify process, and return the
request_idneeded for the verification step.Add the
/request-otpEndpoint: Add the following route handler to yourindex.jsfile, typically placed before the/healthendpoint or server start logic.// index.js // ... (previous setup code) ... // --- API Endpoints --- // POST /request-otp // Initiates an OTP verification request app.post('/request-otp', async (req, res) => { const { phoneNumber } = req.body; // 1. Input Validation if (!isValidPhoneNumber(phoneNumber)) { console.warn(`Invalid phone number format received: ${phoneNumber}`); return res.status(400).json({ success: false, error: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' }); } const brand = process.env.VONAGE_BRAND_NAME || ""MyApp""; // Fallback brand name console.log(`Requesting OTP for phone number: ${phoneNumber} with brand: ${brand}`); try { // 2. Call Vonage Verify API const result = await vonage.verify.start({ number: phoneNumber, brand: brand, // code_length: '6' // Optional: Specify 6 digits (default is 4) // pin_expiry: 300 // Optional: Set expiry time in seconds (default 300 = 5 minutes) // workflow_id: 6 // Optional: Specify SMS -> TTS -> TTS workflow }); // 3. Handle Vonage Response if (result.status === '0') { // Success - Status '0' indicates the request was accepted console.log(`OTP request successful for ${phoneNumber}. Request ID: ${result.request_id}`); return res.status(200).json({ success: true, requestId: result.request_id }); } else { // Vonage returned an error status console.error(`Vonage Verify API error for ${phoneNumber}. Status: ${result.status}, Error: ${result.error_text}`); // Map common Vonage error statuses to user-friendly messages or appropriate HTTP status codes let statusCode = 500; let errorMessage = result.error_text || 'Failed to send OTP.'; if (result.status === '3') { // Invalid number format statusCode = 400; errorMessage = 'Invalid phone number format provided to Vonage.'; } else if (result.status === '10') { // Concurrent verifications to the same number statusCode = 429; // Too Many Requests errorMessage = 'Concurrent verification attempts detected. Please wait before trying again.'; } else if (result.status === '9') { // Partner quota exceeded statusCode = 503; // Service Unavailable errorMessage = 'Verification service quota exceeded. Please try again later.'; } return res.status(statusCode).json({ success: false, error: errorMessage, vonage_status: result.status }); } } catch (error) { // 4. Handle SDK/Network Errors console.error(`Error requesting OTP for ${phoneNumber}:`, error); return res.status(500).json({ success: false, error: 'An unexpected error occurred while requesting the OTP.' }); } }); // ... (rest of the code: /verify-otp, /health, app.listen) ...Explanation:
phoneNumberfrom the JSON request body.isValidPhoneNumbercheck. Returns a 400 Bad Request if invalid. Remember: Use a robust library likegoogle-libphonenumbervia a Node.js wrapper (e.g.,libphonenumber-js) in production for proper E.164 validation and formatting.vonage.verify.start()with the phone number and brand. Optional parameters likecode_length,pin_expiry, andworkflow_idare commented out but show how to customize the process.result.statusis'0', the request was successful. It returns a 200 OK response withsuccess: trueand the crucialrequestId. The client must store thisrequestIdto use in the verification step.result.statusis non-zero, an error occurred at the Vonage API level. We log the details and map common statuses (like '3' for invalid number, '10' for concurrent requests) to appropriate HTTP status codes (400, 429) and user-friendly error messages. A generic 500 error is returned for unmapped or unexpected Vonage errors.try...catchblock handles potential errors during the SDK call itself (e.g., network issues, invalid credentials setup). It returns a 500 Internal Server Error.Verifying the OTP
This endpoint receives the
request_id(obtained from the previous step) and the OTPcodeentered by the user. It asks Vonage to check if the code is correct for that specific request.Add the
/verify-otpEndpoint: Add the following route handler toindex.js, after the/request-otpendpoint.// index.js // ... (previous setup code including /request-otp) ... // POST /verify-otp // Verifies the OTP code submitted by the user app.post('/verify-otp', async (req, res) => { const { requestId, code } = req.body; // 1. Input Validation if (!requestId || typeof requestId !== 'string' || requestId.trim() === '') { return res.status(400).json({ success: false, error: 'Request ID is required.' }); } if (!isValidOtpCode(code)) { console.warn(`Invalid OTP code format received for request ID ${requestId}: ${code}`); return res.status(400).json({ success: false, error: 'Invalid OTP code format. Must be 4-6 digits.' }); } console.log(`Verifying OTP for request ID: ${requestId} with code: ${code}`); try { // 2. Call Vonage Verify Check API const result = await vonage.verify.check(requestId, code); // 3. Handle Vonage Response if (result.status === '0') { // Success - Status '0' means the code was correct console.log(`OTP verification successful for request ID: ${requestId}`); return res.status(200).json({ success: true, message: 'OTP verified successfully.' }); } else { // Vonage returned an error status (e.g., wrong code, expired request) console.warn(`Vonage Verify Check API error for request ID ${requestId}. Status: ${result.status}, Error: ${result.error_text}`); let statusCode = 401; // Unauthorized (or Bad Request 400) is often suitable for failed verification let errorMessage = result.error_text || 'OTP verification failed.'; if (result.status === '16') { // The code provided was incorrect errorMessage = 'Incorrect OTP code provided.'; } else if (result.status === '6') { // The verify request associated with this request_id has expired errorMessage = 'OTP request has expired. Please request a new code.'; statusCode = 410; // Gone (or 400 Bad Request) } else if (result.status === '17') { // A wrong code was provided too many times errorMessage = 'Too many incorrect attempts. Please request a new code.'; statusCode = 429; // Too Many Requests } else if (result.status === '5') { // Invalid request ID format errorMessage = 'Internal error: Invalid request ID format.'; // More likely a server issue statusCode = 500; } return res.status(statusCode).json({ success: false, error: errorMessage, vonage_status: result.status }); } } catch (error) { // 4. Handle SDK/Network Errors console.error(`Error verifying OTP for request ID ${requestId}:`, error); // Check if the error indicates the request ID doesn't exist // Note: The exact error structure (like status '101') can vary based on SDK updates or Vonage API changes. // Always consult the official Vonage documentation and test error responses. // This is an example check: if (error.body && error.body.status === '101' && error.body.error_text === 'No matching request found') { console.warn(`Attempted to verify non-existent or already verified request ID: ${requestId}`); return res.status(404).json({ success: false, error: 'Verification request not found or already completed.' }); } return res.status(500).json({ success: false, error: 'An unexpected error occurred during OTP verification.' }); } }); // ... (/health, app.listen) ...Explanation:
requestIdandcodefrom the request body.requestIdandcodeare present and use the basicisValidOtpCodecheck. Returns 400 Bad Request if invalid.vonage.verify.check()with therequestIdandcode.result.statusis'0', the code was correct. It returns 200 OK withsuccess: true.try...catchhandles SDK or network errors. It includes an example check for a specific error structure (like status '101' - No matching request found) which might indicate therequestIdwas invalid, already used, or expired, returning a 404 Not Found in that case. This specific check might need adjustment based on observed errors from the SDK. Otherwise, it returns a 500 Internal Server Error.3. Integrating with Vonage (Configuration Details)
Properly configuring the Vonage integration involves obtaining credentials and understanding the environment variables used.
Obtain API Key and Secret:
Set Environment Variables:
.envfile you created earlier.VONAGE_API_KEY.VONAGE_API_SECRET.VONAGE_BRAND_NAMEto the name you want displayed in the SMS message (e.g.,VONAGE_BRAND_NAME=""TechCorp Security""). This helps users identify the source of the OTP. Note that Vonage may have character limits or other restrictions on brand names depending on the destination country and regulations; consult the Vonage documentation for details. Also, see the note on Alphanumeric Sender IDs in Section 7.Security: Remember, the
.envfile contains sensitive credentials..gitignorefile.4. Error Handling and Logging
We've already incorporated basic error handling, but let's refine the strategy.
successflag (boolean) and either a relevant payload (likerequestId) on success or anerrormessage (string) on failure. Including thevonage_statusin error responses can aid debugging.console.logfor informational messages (request received, success) andconsole.warnorconsole.errorfor issues.winstonorpino. These offer features like:requestIdand phone number (potentially masked) for correlation.Example of enhancing logging (conceptual):
// Conceptual enhancement with a logger library (e.g., Winston) // const winston = require('winston'); // const logger = winston.createLogger({ /* ... configuration ... */ }); // Inside /request-otp error handler: // logger.error(`Vonage Verify API error for ${phoneNumber}. Status: ${result.status}, Error: ${result.error_text}`, { phoneNumber, vonageStatus: result.status }); // Inside /verify-otp catch block: // logger.error(`Error verifying OTP for request ID ${requestId}:`, { error, requestId });5. Security Features
Implementing OTP is a security feature itself, but the implementation needs protection.
Input Validation:
libphonenumber-js) to validate and potentially format phone numbers into the E.164 standard (+14155552671) before sending them to Vonage. This prevents errors and potential injection issues.requestIdlooks like a valid identifier (though Vonage handles the actual validation).Secure Credential Storage: Use environment variables and secure management practices, never hardcode secrets.
Rate Limiting: This is critical for OTP endpoints:
requestIdor from a specific IP address.express-rate-limit. Apply separate limits for requesting OTPs (e.g., 5 requests per phone number per hour) and verifying codes (e.g., 5 attempts perrequestId, 10 attempts per IP per minute).# Install rate limiter npm install express-rate-limit// index.js - Add near the top after `app = express()` const rateLimit = require('express-rate-limit'); // Rate limiter for requesting OTPs (e.g., per IP) const requestOtpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Limit each IP to 10 OTP requests per windowMs message: { success: false, error: 'Too many OTP requests created from this IP, please try again after 15 minutes' }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Rate limiter for verifying OTPs (e.g., per IP) const verifyOtpLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 5, // Limit each IP to 5 verification attempts per windowMs (adjust based on Vonage's attempt limits) message: { success: false, error: 'Too many verification attempts from this IP, please try again after 5 minutes' }, standardHeaders: true, legacyHeaders: false, }); // Apply limiters to specific routes app.use('/request-otp', requestOtpLimiter); app.use('/verify-otp', verifyOtpLimiter); // ... (rest of the app setup and routes)Note: IP-based limiting is basic. More sophisticated limiting might involve user accounts or phone numbers, requiring session management or a database.
HTTPS: Always serve your application over HTTPS in production to encrypt communication between the client and your server. Use a reverse proxy like Nginx or Caddy, or platform services (Heroku, AWS Load Balancer) to handle TLS termination.
6. Database Schema and Data Layer (Considerations)
While this guide doesn't implement a database, a real-world application almost certainly needs one:
request_id: You need to associate therequestIdreturned by/request-otpwith the user's session or account attempting verification. When the user submits the code to/verify-otp, you retrieve the correctrequestIdfrom their session/database record.Example Schema Snippets (Conceptual - using Prisma as an example):
This requires setting up a database, an ORM like Prisma or Sequelize, and integrating data access logic into your route handlers.
7. Troubleshooting and Caveats
VONAGE_API_KEYandVONAGE_API_SECRETin your.envfile are correct and have no extra spaces. Error messages often include ""authentication failed"" or similar.+14155552671). Using local formats might work for some regions but can be unreliable. Use a library for robust validation/formatting. Vonage status3often indicates this.10. Implement logic to prevent users from rapidly clicking ""Resend Code"" or handle this error gracefully. Consider adding cancellation logic if needed (usingvonage.verify.cancel(REQUEST_ID)).6. Inform the user the code expired and prompt them to request a new one.16. After too many attempts (Vonage default might be 5), status17occurs. Implement attempt tracking on your side or rely on Vonage's limits, informing the user clearly.express-rate-limitor hit Vonage's own internal limits, requests will fail (often with HTTP 429). Ensure your limits are reasonable. Vonage status9indicates hitting their platform quota.requestIdthat was never generated, already verified, or cancelled might result in Vonage status101(No matching request found - often surfaced as an SDK error rather than a non-zero status in thecheckresult) or potentially status5. Return a 404 or 400 error.VONAGE_BRAND_NAMEBehavior): Using a brand name as the sender ID depends heavily on carrier support and regulations in the destination country. In some regions (like the US), carriers might replace the alphanumeric sender ID with a shared short code or long code number pool managed by Vonage for compliance reasons. This means theVONAGE_BRAND_NAMEyou set might not always appear as the sender. Check Vonage documentation for country-specific sender ID behavior and potential registration requirements for Alphanumeric Sender IDs where supported.8. Deployment and CI/CD
.envfiles to your production repository.Dockerfileor deployment script includes compiling the code. For plain JavaScript, usually just need to copy files and install production dependencies (npm install --omit=dev).PORTBinding: Ensure your application listens on the port specified by the environment (oftenprocess.env.PORT), not a hardcoded port like3000. Our code already does this (process.env.PORT || 3000).mainbranch or tag creation.npm ci(usespackage-lock.jsonfor deterministic installs).eslint,npm test.npm run build(if applicable)./healthendpoint).Example
Dockerfile(Simple):