How to Build SMS OTP 2FA with Vonage, Node.js & Fastify: Complete 2025 Guide
Learn how to implement SMS OTP two-factor authentication using Vonage Verify API with Node.js and Fastify. Step-by-step tutorial with security best practices, rate limiting, and production deployment guidance.
SMS OTP two-factor authentication (2FA) adds critical security by sending One-Time Passwords to users' mobile devices during login or sensitive operations. This comprehensive guide teaches you how to implement production-ready SMS OTP verification using Node.js, the high-performance Fastify framework, and the Vonage Verify API v2. You'll learn security best practices, implement rate limiting, handle errors gracefully, and write automated tests for reliable authentication.
Build a complete backend API with two secure endpoints: one to request an OTP code sent via SMS to a user's phone number in E.164 format, and another to verify the OTP code entered by the user.
What Are You Building with Vonage and Fastify?
What You're Building:
A backend API service built with Node.js and Fastify that:
Accepts a user's phone number.
Uses the Vonage Verify API to send an SMS containing a unique OTP to that number.
Provides an endpoint to verify the OTP submitted by the user against the one sent by Vonage.
Problem Solved:
This implementation addresses the need for secure, out-of-band verification commonly used during:
User registration: Verify phone numbers before creating accounts
Login authentication: Add a second factor beyond passwords
Password resets: Confirm identity before allowing password changes
Transaction authorization: Verify high-value or sensitive actions
Account recovery: Validate users when they lose access credentials
Technologies Used:
Node.js: JavaScript runtime environment for building server-side applications.
Fastify: High-performance, low-overhead web framework for Node.js. Choose this for its speed, extensibility, and developer-friendly features like built-in validation and logging.
Vonage Verify API: Service that handles OTP generation, delivery (SMS, voice), and verification logic. Use this to avoid building and maintaining your own OTP system.
dotenv: Module to load environment variables from a .env file into process.env.
@vonage/server-sdk: Official Vonage Node.js SDK for interacting with Vonage APIs.
System Architecture:
Your system follows this flow:
Step
Component
Action
1
User
Provides phone number
2
Client App → Fastify API
Sends phone number to /request-otp
3
Fastify API → Vonage
Requests OTP generation and delivery
4
Vonage → User
Sends SMS with OTP code
5
User → Client App
Enters received OTP code
6
Client App → Fastify API
Sends OTP code and request ID to /verify-otp
7
Fastify API → Vonage
Verifies the OTP code
8
Vonage → Fastify API
Confirms or denies verification
9
Fastify API → Client App
Returns verification result
10
Client App → User
Displays success or error message
Prerequisites:
Node.js and npm (or yarn): Ensure Node.js v20 or later is installed (Fastify v5 requires Node.js v20+ for native async hooks and performance optimizations). Download Node.js
Vonage API Account: Required to get API credentials. Sign up for Vonage.
Vonage API Key and Secret: Found on your Vonage API Dashboard after signing up.
(Optional) Vonage CLI: Useful for managing Vonage applications and numbers. Install via npm install -g @vonage/cli.
Basic understanding of Node.js, APIs, and asynchronous JavaScript.
Technology Versions Used in This Guide:
Fastify: v5.x (latest stable release as of 2025, requires Node.js v20+)
@vonage/server-sdk: v3.24+ (latest as of October 2025)
Node.js: v20+ recommended for best compatibility
Note on Vonage Verify API: This guide uses the Vonage Verify v2 API, which supports multiple authentication channels including SMS, Voice, Email, WhatsApp, and Silent Authentication. The API automatically handles OTP generation, delivery, expiration, and verification logic.
Important Security Considerations:
Phone Number Format: Always use E.164 format for phone numbers (e.g., +14155552671 for US numbers, +442071234567 for UK numbers). The Vonage API expects this international format and may reject improperly formatted numbers. Common format errors: missing + prefix, including spaces or dashes, omitting country code.
Vonage Built-in Protections: The Vonage Verify API includes built-in fraud protection, monitoring for unusual traffic patterns and automatically implementing security measures. The API also enforces its own rate limits to prevent abuse.
Cost Awareness: Each OTP request costs approximately $0.06–$0.10 USD depending on destination country. Implement proper rate limiting and validation to prevent malicious actors from generating excessive verification requests. Consider adding CAPTCHA before OTP requests for high-risk endpoints.
OTP Expiration: Vonage automatically handles OTP expiration (typically 5–10 minutes depending on workflow). Expired codes cannot be verified, requiring users to request a new OTP.
A functional Fastify API service capable of sending and verifying SMS OTPs via Vonage, ready for integration into a larger application. The service will include basic security, error handling, and logging.
How Does SMS OTP 2FA Work?
OTP Generation and Security:
The Vonage Verify API generates cryptographically secure random codes (typically 4–6 digits) that are valid for a limited time window. This approach provides:
Time-based expiration: Codes become invalid after 5–10 minutes, limiting the window for attacks
Single-use tokens: Each code can only be verified once, even if correct
Attempt throttling: After 3–5 failed verification attempts, the request is blocked
Out-of-band delivery: Sending codes via SMS separates the authentication channel from the primary application channel, making phishing harder
Verification Flow:
Your API requests an OTP from Vonage with a phone number
Vonage generates a random code and stores it with a unique request_id
Vonage sends the SMS containing the code to the user's phone
User receives the SMS and enters the code in your application
Your API sends the request_id and user-entered code to Vonage
Vonage compares the submitted code against the stored code
Vonage returns success (status 0) if the code matches and is still valid, or an error status if it doesn't match, expired, or was already used
Security Principles:
Possession factor: The user must possess the physical device receiving the SMS
Short-lived credentials: Time limits reduce replay attack windows
Rate limiting: Both your API and Vonage implement rate limits to prevent brute-force attacks
Audit trail: All verification attempts are logged for security monitoring
How Do You Set Up a Fastify Project for OTP Verification?
Initialize your Node.js project and install Fastify along with necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir fastify-vonage-otp
cd fastify-vonage-otp
2. Initialize npm Project:
Bash
npm init -y
This creates a package.json file with default settings.
3. Install Dependencies:
Install Fastify, the Vonage SDK, dotenv for environment variables, and pino-pretty for development logging.
src/server.js: The main entry point for the Fastify application.
.env: Stores sensitive information like API keys (DO NOT commit this file).
.gitignore: Specifies intentionally untracked files that Git should ignore.
6. Configure .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing them.
Text
# .gitignore
node_modules/
.env
*.log
7. Set Up Environment Variables:
Open the .env file and add your Vonage API credentials and a brand name for the SMS message.
Text
# .env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME="YourAppName" # Appears in SMS as "Your YourAppName code is: 1234". Keep under 11 characters to avoid SMS segmentation.
PORT=3000 # Optional: Define the port for the server
Replace YOUR_API_KEY and YOUR_API_SECRET with the actual credentials from your Vonage API Dashboard.
8. Basic Fastify Server Setup:
Create the initial server configuration in src/server.js.
JavaScript
// src/server.js// Load environment variables earlyrequire('dotenv').config();
const fastify = require('fastify')({
logger: true// Enable built-in Pino logger
});
// --- Plugin Registration ---// Example: Register formbody parser if needed later for simpler forms// fastify.register(require('@fastify/formbody'));// --- Vonage Client Setup (Placeholder) ---// We will add Vonage initialization here later// --- Route Registration (Placeholder) ---// We will register our OTP routes here// --- Error Handling (Placeholder) ---// We will add custom error handling here// --- Start Server ---conststart = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
require('dotenv').config();: Loads variables from .env into process.env. Crucially done before other modules might need them.
fastify({ logger: true }): Initializes Fastify with logging enabled. Pino is used by default.
fastify.listen(): Starts the server. We listen on 0.0.0.0 to make it accessible within Docker containers or VMs if needed later.
Run npm run dev in your terminal. You should see log output indicating the server is running, likely on port 3000. Stop the server with Ctrl+C.
How Do You Integrate the Vonage Verify API?
Initialize the Vonage SDK client and make it available within your Fastify application.
1. Initialize Vonage Client:
Update src/server.js to create and configure the Vonage client using the environment variables. We'll use Fastify's decorate utility to make the client accessible within route handlers via request.vonage or fastify.vonage.
JavaScript
// src/server.jsrequire('dotenv').config();
const fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk'); // Import Vonage// --- Validate Environment Variables ---if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
process.exit(1);
}
// --- Plugin Registration ---// fastify.register(require('@fastify/formbody'));// --- Vonage Client Setup ---const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// Decorate Fastify instance/request with the Vonage client
fastify.decorate('vonage', vonage);
// --- Route Registration (Placeholder) ---// We will register our OTP routes here// --- Error Handling (Placeholder) ---// We will add custom error handling here// --- Start Server ---conststart = async () => {
// ... (rest of the start function remains the same)try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
We import the Vonage class from the SDK.
We validate that required environment variables exist before instantiating the Vonage client. This prevents cryptic runtime errors.
We instantiate Vonage using the API key and secret loaded from .env.
fastify.decorate('vonage', vonage) adds the vonage instance to Fastify's application context, making it easily accessible in routes and plugins.
How Do You Implement the OTP Request and Verification Flow?
Create two API endpoints within a dedicated route file:
POST /request-otp: Initiates the OTP process.
POST /verify-otp: Verifies the submitted OTP.
1. Create Route File:
Create a new file src/routes/otp.js.
Bash
touch src/routes/otp.js
2. Define Routes:
Add the route logic to src/routes/otp.js. Use Fastify's schema validation for request bodies.
JavaScript
// src/routes/otp.jsasyncfunctionotpRoutes(fastify, options) {
// Schema for the /request-otp endpoint bodyconst requestOtpSchema = {
body: {
type: 'object',
required: ['phoneNumber'],
properties: {
phoneNumber: { type: 'string', description: 'User phone number in E.164 format (e.g., +14155552671)' }
},
additionalProperties: false// Disallow extra fields
}
};
// Schema for the /verify-otp endpoint bodyconst verifyOtpSchema = {
body: {
type: 'object',
required: ['requestId', 'code'],
properties: {
requestId: { type: 'string', description: 'The request ID received from /request-otp' },
code: { type: 'string', minLength: 4, maxLength: 6, description: 'The OTP code entered by the user' }
},
additionalProperties: false// Disallow extra fields
}
};
// --- Endpoint: Request OTP ---
fastify.post('/request-otp', { schema: requestOtpSchema }, async (request, reply) => {
const { phoneNumber } = request.body;
const vonage = fastify.vonage; // Access Vonage client via decoratorconst brand = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Use brand from .env or default
request.log.info(`Requesting OTP for phone number: ${phoneNumber}`);
try {
const response = await vonage.verify.start({
number: phoneNumber,
brand: brand,
// code_length: '6' // Optional: Uncomment to request a 6-digit code (default is 4)// workflow_id: 6 // Optional: Controls delivery method. Default (1): SMS only. 6: SMS → Voice → Voice for better delivery.
});
if (response.status === '0') {
request.log.info(`OTP request successful, requestId: ${response.request_id}`);
return reply.send({ success: true, requestId: response.request_id });
} else {
// Non-zero status indicates an error from Vonage before sending
request.log.error(`Vonage verify start error for ${phoneNumber}: Status ${response.status} – ${response.error_text}`);
return reply.status(500).send({ success: false, message: response.error_text || 'Failed to initiate OTP verification.' });
}
} catch (error) {
request.log.error({ err: error, phoneNumber }, 'Error calling Vonage verify start API');
// Handle network errors or SDK issuesreturn reply.status(500).send({ success: false, message: 'An internal server error occurred while requesting OTP.' });
}
});
// --- Endpoint: Verify OTP ---
fastify.post('/verify-otp', { schema: verifyOtpSchema }, async (request, reply) => {
const { requestId, code } = request.body;
const vonage = fastify.vonage; // Access Vonage client
request.log.info(`Verifying OTP for requestId: ${requestId}`);
try {
const response = await vonage.verify.check(requestId, code);
if (response.status === '0') {
// Status '0' means successful verification
request.log.info(`OTP verification successful for requestId: ${requestId}`);
return reply.send({ success: true, message: 'OTP verified successfully.' });
} else {
// Handle specific Vonage error statuses
request.log.warn(`OTP verification failed for requestId ${requestId}: Status ${response.status} – ${response.error_text}`);
let userMessage = 'OTP verification failed.';
let statusCode = 400; // Bad Request by default for verification failures// Customize messages based on common statuses (refer to Vonage docs for full list)switch (response.status) {
case'6': // The code provided does not match the expected value
userMessage = 'Invalid or incorrect OTP code provided.';
break;
case'16': // The request specified by the request_id has already been verified
userMessage = 'This request has already been verified.';
statusCode = 409; // Conflictbreak;
case'17': // The wrong code was provided too many times
userMessage = 'Too many incorrect attempts. Request a new OTP.';
break;
case'101': // No response found (e.g., expired request_id)
userMessage = 'Verification request not found or expired. Request a new OTP.';
statusCode = 404; // Not Foundbreak;
default:
userMessage = response.error_text || 'OTP verification failed.';
}
return reply.status(statusCode).send({ success: false, message: userMessage, errorCode: response.status });
}
} catch (error) {
// Handle potential SDK errors or network issues during check
request.log.error({ err: error, requestId }, 'Error calling Vonage verify check API');
// Check if the error is a Vonage specific error structure (might vary by SDK version)// Note: The structure of Vonage SDK errors (`error.body`) can change between versions. Always consult the documentation for the specific SDK version you are using.if (error.body && error.body.status && error.body.error_text) {
request.log.error(`Vonage API Error during check: Status ${error.body.status} – ${error.body.error_text}`);
return reply.status(error.statusCode || 500).send({ success: false, message: error.body.error_text || 'An internal error occurred during OTP verification.' });
}
return reply.status(500).send({ success: false, message: 'An internal server error occurred during OTP verification.' });
}
});
}
module.exports = otpRoutes;
Explanation:
otpRoutes(fastify, options): Standard Fastify plugin structure.
Schemas (requestOtpSchema, verifyOtpSchema): Define the expected structure and types for the request bodies. Fastify automatically validates incoming requests against these schemas and returns a 400 Bad Request error if validation fails. additionalProperties: false prevents unexpected fields.
/request-otp:
Retrieves phoneNumber from the validated request.body.
Accesses the decorated vonage client.
Calls vonage.verify.start() with the phone number and brand name.
Crucially: Checks the response.status. A status of '0' means Vonage accepted the request and will attempt to send the SMS. Other statuses mean an error occurred before sending (e.g., invalid number format, throttling).
Returns the requestId on success, which the client needs for the verification step.
Includes basic try...catch for network/SDK errors.
/verify-otp:
Retrieves requestId and code from the validated request.body.
Calls vonage.verify.check() with the requestId and the user-provided code.
Checks response.status: '0' means the code was correct.
Handles common non-zero statuses (invalid code, already verified, too many attempts, expired) by returning appropriate HTTP status codes (400, 409, 404) and user-friendly error messages.
Includes try...catch for network/SDK errors during the check.
fastify.register(otpRoutes, { prefix: '/api/v1' }): Registers all routes defined in otp.js under the /api/v1 path prefix (e.g., /api/v1/request-otp). Using a prefix is good practice for API versioning.
At this point, you have the core API functionality. Test it using curl or a tool like Postman after starting the server (npm run dev).
Testing with curl:
Replace +1XXXXXXXXXX with a real phone number you can receive SMS on (use E.164 format).
{"success":false,"message":"Invalid or incorrect OTP code provided.","errorCode":"6"}
(Note: The HTTP status code will be 400 for incorrect code)
Common Testing Issues:
Issue
Cause
Solution
SMS not received
Invalid phone number format
Verify E.164 format with country code
Error 401 or 403
Invalid API credentials
Check .env file has correct credentials
Error 429
Rate limit exceeded
Wait before making more requests
Connection timeout
Network or firewall issue
Check internet connection and firewall rules
Verification fails despite correct code
Request expired (>10 min)
Request a new OTP
How Do You Add Error Handling and Logging?
Fastify's built-in logger (Pino) is already active. Add a centralized error handler to catch unhandled exceptions and format error responses consistently.
1. Add Custom Error Handler:
Update src/server.js to include setErrorHandler.
JavaScript
// src/server.jsrequire('dotenv').config();
const fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp');
// --- Validate Environment Variables ---if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
process.exit(1);
}
// --- Plugin Registration ---// fastify.register(require('@fastify/formbody'));// --- Vonage Client Setup ---const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);
// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' });
// --- Error Handling ---
fastify.setErrorHandler(function (error, request, reply) {
// Log the error
request.log.error(error);
// Check if it's a validation error from Fastify schema checkif (error.validation) {
reply.status(400).send({
success: false,
message: 'Validation error',
errors: error.validation, // Provides details on which fields failed
});
return;
}
// Check for specific error types you might throw or receive// Example: if (error instanceof MyCustomError) { ... }// Default fallback for other errors// Use the status code from the error if available (e.g., from Vonage errors handled in routes)const statusCode = error.statusCode || 500;
reply.status(statusCode).send({
success: false,
message: error.message || 'An unexpected internal server error occurred.',
// Avoid leaking stack traces in production environments
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
});
});
// --- Start Server ---conststart = async () => {
// ... (start function)try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
fastify.setErrorHandler: Registers a function to handle errors that occur during request processing after the initial routing but before a reply is sent, or errors explicitly passed to reply.send(error).
It logs the full error using request.log.error(error).
It checks if the error is a Fastify validation error (error.validation) and returns a structured 400 response.
It provides a generic response for other unhandled errors, using the error's statusCode and message if available (falling back to 500).
Important: It conditionally includes the stack trace only if NODE_ENV is not 'production' to avoid leaking sensitive information.
Logging Best Practices:
Fastify automatically logs incoming requests and outgoing responses.
We added specific logging within the route handlers (request.log.info, request.log.error, request.log.warn) to provide context about the OTP flow.
During development (npm run dev), pino-pretty formats these JSON logs nicely. In production, pipe the raw JSON logs to a log management system (e.g., Datadog, ELK stack, Splunk) for analysis and alerting.
Key Log Queries for Monitoring:
Failed verifications:status:"failed" AND requestId:* – Identify suspicious patterns
Rate limit hits:message:"Rate limit exceeded" – Adjust limits if legitimate users are blocked
Vonage API errors:error_text:* AND status:!0 – Track API reliability
High-cost phone numbers:phoneNumber:"+9*" – Monitor international SMS costs
What Security Features Does Your OTP System Need?
Security is paramount for an authentication mechanism.
Register and Configure: Add this to the "Plugin Registration" section in src/server.js.
JavaScript
// src/server.js// ... other importsrequire('dotenv').config(); // Ensure this is at the topconst fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp');
// --- Validate Environment Variables ---if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET');
process.exit(1);
}
// --- Plugin Registration ---
fastify.register(require('@fastify/rate-limit'), {
max: 100, // Max requests per timeWindow per IP (adjust globally)timeWindow: '1 minute',
// Optional: customize error responseerrorResponseBuilder: function (req, context) {
return {
success: false,
message: `Rate limit exceeded, retry after ${context.after}`,
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: context.ttl, // Time-to-live seconds for the ban
}
},
// Optional: Key generator (e.g., use userId if authenticated)// keyGenerator: function (req) { return req.headers['x-real-ip'] || req.ip } // Default is IP
});
// Register Helmet here too (see step 4 below)// fastify.register(require('@fastify/helmet'));// --- Vonage Client Setup ---const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);
// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' });
// --- Error Handling ---// ... (setErrorHandler from Section 4)
fastify.setErrorHandler(function (error, request, reply) {
request.log.error(error);
if (error.validation) {
reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation });
return;
}
const statusCode = error.statusCode || 500;
reply.status(statusCode).send({
success: false,
message: error.message || 'An unexpected internal server error occurred.',
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
});
});
// --- Start Server ---conststart = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' });
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Recommended Rate Limit Configuration:
Endpoint
Max Requests
Time Window
Rationale
/request-otp
3–5
1 hour per IP
Prevents SMS flooding, reduces costs
/verify-otp
5–10
15 minutes per IP
Allows for typos while preventing brute force
Global API
100
1 minute per IP
General protection against DoS
Apply Specific Limits (Optional but Recommended): You can override the global settings within specific route options in src/routes/otp.js. It's wise to have stricter limits on OTP requests than general API usage.
JavaScript
// src/routes/otp.js// ... schemas ...asyncfunctionotpRoutes(fastify, options) {
// --- Endpoint: Request OTP ---
fastify.post('/request-otp', {
schema: requestOtpSchema,
config: { // Add rate limit config specific to this routerateLimit: {
max: 5, // Allow only 5 requests per hour per keytimeWindow: '1 hour',
// WARNING: Using `phoneNumber` directly in `keyGenerator` can block legitimate users sharing IPs (e.g., behind NAT).// This approach is generally safer *only if* user authentication happens *before* the OTP request.// Otherwise, IP-based limiting (`req.ip`) is usually the safer default starting point. Combining IP and phone number is another option.// keyGenerator: function (req) { return req.body.phoneNumber || req.ip }
}
}
}, async (request, reply) => {
// ... route handler logic ...
});
// --- Endpoint: Verify OTP ---
fastify.post('/verify-otp', {
schema: verifyOtpSchema,
config: { // Add rate limit config specific to this routerateLimit: {
max: 10, // Allow slightly more verification attempts per keytimeWindow: '15 minutes'// Keying by requestId might be useful here, combined with IP// keyGenerator: function (req) { return `${req.ip}-${req.body.requestId}` }
}
}
}, async (request, reply) => {
// ... route handler logic ...
});
}
module.exports = otpRoutes;
Explanation:
@fastify/rate-limit: Provides robust rate limiting based on IP address by default.
max, timeWindow: Control how many requests are allowed within a specific timeframe.
errorResponseBuilder: Customizes the response when the limit is hit.
keyGenerator: Allows using factors other than IP (like phone number, requestId, or authenticated user ID) for more granular limiting. Use with caution, as poorly chosen keys can block legitimate users. IP-based is often the simplest starting point.
Route-specific config.rateLimit overrides the global settings for finer control.
2. Input Validation and Sanitization:
Fastify's schema validation (used in Section 3) handles basic input validation (checking types, required fields, lengths). This prevents many common injection-style attacks by ensuring data conforms to expectations before it's processed.
For phone numbers, Vonage performs its own validation, but using a library like google-libphonenumber on the backend before sending to Vonage provides earlier, more specific feedback to the user about formatting issues.
Environment Variables: API keys are correctly stored in .env and loaded via dotenv.
.gitignore: The .env file is in .gitignore to prevent accidental commits.
Production: In production environments (like Docker, PaaS, servers), use the hosting provider's mechanism for managing environment variables securely (e.g., secrets management tools, platform environment variable settings). Do not deploy .env files directly to production servers.
4. Other Considerations:
HTTPS: Always run your API over HTTPS in production to encrypt data in transit. Use a reverse proxy like Nginx or Caddy, or platform-provided SSL termination (e.g., AWS ELB, Heroku).
Helmet: Consider using @fastify/helmet to set various security-related HTTP headers (like X-Frame-Options, Strict-Transport-Security).
Bash
npm install @fastify/helmet
Register it in src/server.js within the "Plugin Registration" section:
JavaScript
// src/server.js - Ensure this is registered in the Plugin Registration section
fastify.register(require('@fastify/helmet'));
How Do You Test Your SMS OTP API?
Write automated tests to ensure reliability. Use tap, Fastify's default test runner.
(Note: Added @fastify/helmet, @fastify/rate-limit to dependencies and tap, pino-pretty to devDependencies based on previous steps. Ensure versions match your installation.)
A file like test/helper.js would typically contain a function to build and configure the Fastify app instance for testing, allowing injection of mocks and handling teardown. Creating a full helper is beyond the scope of this guide, but the test file structure assumes its existence.
Run tests using npm test. Add comprehensive tests covering success paths, error paths (Vonage errors, validation errors), and security features like rate limiting.
Integration Testing with Vonage:
For integration tests with real Vonage test numbers:
Create a separate test environment with dedicated Vonage credentials
Use Vonage test numbers (start with +15005550) that don't send real SMS
Test with +15005550006 – Vonage treats this as valid but doesn't deliver SMS
Monitor your Vonage dashboard for test request logs
Keep integration tests separate from unit tests (test/integration/) to avoid incurring costs during development
What Should You Consider When Deploying to Production?
Follow these critical best practices when deploying your SMS OTP service to production:
Environment Variables and Secrets Management:
Never deploy .env files to production servers. Use your hosting platform's environment variable management (AWS Systems Manager, Heroku Config Vars, Google Secret Manager, Azure Key Vault).
Rotate API credentials periodically and immediately if compromised.
Use different Vonage API credentials for development, staging, and production environments.
HTTPS and Transport Security:
Always use HTTPS in production to encrypt data in transit. Use a reverse proxy (Nginx, Caddy) or platform-provided SSL termination (AWS ALB, Cloudflare).
The @fastify/helmet plugin (covered in Section 5) sets important security headers including Strict-Transport-Security (HSTS).
Logging and Monitoring:
In production, pipe Fastify's JSON logs to a log aggregation service (Datadog, ELK Stack, Splunk, Papertrail) for analysis and alerting.
Key Metrics to Monitor:
Metric
Threshold
Action
OTP request rate
>1000/hour
Investigate for abuse, check rate limits
Verification failure rate
>30%
Check for UX issues or attack patterns
API response time (p95)
>500ms
Scale infrastructure or optimize code
Vonage API errors
>5%
Check API status, validate credentials
Rate limit hits
>100/hour
Review if legitimate users are blocked
Alert Patterns:
Spike in failed verifications: May indicate brute-force attack
Increased OTP requests from single IP: Potential DoS or abuse
Unusual geographic patterns: Monitor requests from unexpected countries
Cost threshold exceeded: Set billing alerts in Vonage dashboard
Rate Limiting Tuning:
Start with conservative rate limits and adjust based on legitimate usage patterns.
Consider implementing different rate limit tiers for authenticated vs. anonymous users.
Monitor rate limit hits to identify potential attacks or legitimate users being blocked.
Testing Before Production:
Test the complete OTP flow with real phone numbers in a staging environment.
Verify that error handling works correctly for all Vonage API error statuses.
Load test your API endpoints to understand performance limits and identify bottlenecks.
Test rate limiting behavior under realistic traffic patterns.
Disaster Recovery and Failover:
Database backups: If storing verification logs, implement automated backups with point-in-time recovery
Multi-region deployment: Deploy to multiple regions for high availability
Fallback workflows: Implement alternative verification methods (email, voice) if SMS delivery fails
Circuit breakers: Use circuit breaker patterns to handle Vonage API outages gracefully
Health checks: Implement /health endpoints that verify Vonage API connectivity
Compliance and Privacy:
Ensure your OTP implementation complies with telecommunications regulations:
Region
Regulation
Requirements
United States
TCPA
Obtain prior express written consent before sending SMS
European Union
GDPR
Implement data retention limits, provide opt-out mechanisms
Canada
CASL
Get explicit consent, include identification in messages
Implement OTP verification in Node.js by using the Vonage Verify API with Fastify. Install the @vonage/server-sdk, create routes for sending OTP codes via /request-otp, and verify user-submitted tokens via /verify-otp. Use vonage.verify.start() to generate and send OTP codes, then vonage.verify.check() to validate user input against the codes stored by Vonage.
What is the Vonage Verify API?
The Vonage Verify API handles OTP generation, SMS delivery, and token verification for two-factor authentication. It automatically generates cryptographically secure random codes (4–6 digits), sends them via SMS, voice, email, or WhatsApp, and validates user-submitted tokens within configurable timeout windows (5–10 minutes by default).
Why use Fastify for SMS authentication?
Use Fastify for SMS authentication because it provides 6x faster performance than Express with built-in schema validation via JSON Schema and a robust plugin ecosystem. Fastify v5's native async/await support and low overhead make it ideal for high-throughput authentication services handling thousands of concurrent OTP requests.
What Node.js version is required for this tutorial?
This tutorial requires Node.js v20 or later. Fastify v5 specifically requires Node.js v20+ for native async hooks and performance optimizations. The @vonage/server-sdk v3.24+ works with Node.js v14+ but performs best on v20+.
How long do Vonage OTP codes remain valid?
Vonage OTP codes remain valid for 5–10 minutes by default, depending on the workflow configuration. You can configure expiration by setting the workflow_id parameter when calling verify.start(). Expired codes cannot be verified and require users to request a new OTP.
How do I secure my Vonage API credentials?
Store Vonage API keys in environment variables via .env files (never commit to version control). Use live keys (starting with live_) for production and test keys for development. Consider IP whitelisting in the Vonage dashboard if your server uses static IPs, and monitor usage for anomalies via the dashboard.
What is E.164 phone number format?
E.164 is the international phone number format used by telecommunications systems. It starts with a + followed by the country code and national number with no spaces or special characters (e.g., +14155552671 for US, +442071234567 for UK). The Vonage API requires E.164 format and may reject improperly formatted numbers.
How do I implement rate limiting for OTP endpoints?
Implement rate limiting using @fastify/rate-limit plugin. Configure stricter limits for OTP request endpoints (3–5 requests per hour per IP) compared to verification endpoints (5–10 per 15 minutes). This prevents SMS flooding, reduces costs, and protects against brute-force attacks while allowing legitimate user typos.
Can I use Vonage OTP for login authentication?
Yes, Vonage OTP is ideal for login authentication as a second factor beyond passwords. After users enter their username and password, send an OTP to their registered phone number via /request-otp, then verify the code they enter via /verify-otp. This significantly enhances account security against credential theft. For more authentication patterns, see our guide on implementing two-factor authentication in Node.js.
How much does each Vonage OTP request cost?
Each Vonage OTP request costs approximately $0.06–$0.10 USD depending on the destination country. Implement proper rate limiting, phone number validation, and consider adding CAPTCHA before OTP requests to prevent malicious actors from generating excessive verification requests that increase costs.
Conclusion
You've built a production-ready SMS OTP verification system using Fastify and the Vonage Verify API. This implementation includes:
Two secure API endpoints for requesting and verifying OTPs
Comprehensive error handling and logging
Rate limiting protection against abuse
Input validation and security headers
Automated testing framework
Production deployment guidance
Next Steps:
Integrate this API into your application with these specific patterns:
Frontend Integration:
JavaScript
// Request OTP during user registrationasyncfunctionrequestOTP(phoneNumber) {
const response = awaitfetch('/api/v1/request-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber })
});
const data = await response.json();
return data.requestId; // Store this for verification
}
// Verify OTP when user submits codeasyncfunctionverifyOTP(requestId, code) {
const response = awaitfetch('/api/v1/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, code })
});
returnawait response.json();
}
User Account Association:
Store verified phone numbers in your user database after successful verification
Link the requestId to user sessions during registration/login flows
Implement phone number change workflows that require re-verification
This guide provides a solid foundation for implementing secure SMS-based two-factor authentication in your Node.js applications.
Frequently asked questions
Use the Vonage Verify API with Fastify and Node.js. Create a Fastify API endpoint that takes the user's phone number and makes a request to the Vonage Verify API to initiate the OTP process. This sends a unique OTP to the user's phone number via SMS. The response includes a request ID for verification.
The Vonage Verify API handles generating, sending, and verifying one-time passwords (OTPs) for two-factor authentication (2FA). It simplifies the process by providing pre-built logic and infrastructure for OTP delivery via SMS or voice calls. This offloads the complexity of building and maintaining an OTP system yourself.
Fastify is a high-performance Node.js web framework known for its speed and extensibility. Its built-in features like schema validation, logging, and a plugin-friendly architecture make it a strong choice for building robust and maintainable APIs. The low overhead also adds to improved efficiency.
Implement SMS OTP/2FA for actions requiring enhanced security, such as user registration, login, password resets, and sensitive transactions. This provides a strong second layer of authentication. The Vonage Verify API makes implementing 2FA easier.
While the default is 4 digits, you can configure Vonage to send 6-digit OTPs or define custom workflows. Uncomment and adjust the code_length or workflow_id options in the vonage.verify.start() call in the Node.js code for customizations.
You'll need Node.js v16 or later with npm/yarn, a Vonage API account with an API key and secret, and a basic understanding of APIs and asynchronous JavaScript. Installing the Vonage CLI is optional but helpful. These steps allow you to get up and running quickly.
Your Fastify API should have a separate endpoint that receives the requestId (from the OTP request) and the code entered by the user. Call vonage.verify.check() to verify the OTP against Vonage's records and return a verification success or failure status to the client app based on the response.
The dotenv module loads environment variables from a .env file into process.env. This is crucial for keeping sensitive data like your Vonage API credentials secure. The file is put in the .gitignore file and not committed to the repository.
Fastify uses JSON Schema to validate request bodies. Define the expected request structure in route options. Fastify automatically checks incoming requests and returns a 400 Bad Request error if they don't match, enhancing security and providing clear error messaging to clients.
A status of '0' in the Vonage Verify API response means the request was successfully initiated. This typically appears after calling vonage.verify.start() indicating Vonage has accepted the request to send the OTP. It does not guarantee delivery.
Adding prefixes like /api/v1 to your API routes is a best practice for versioning. This allows you to introduce future versions (e.g., /api/v2) with breaking changes without impacting existing integrations that rely on the older version.
Check the status code in the Vonage Verify API response. Non-zero status codes indicate an error and should be handled appropriately. The error_text provides further details. Use a centralized error handler in Fastify and custom messages for specific error codes.
Use the @fastify/rate-limit plugin to protect your OTP endpoints from abuse. Configure global limits and/or per-route limits to control how often clients can request or verify OTPs. Use the keyGenerator option to customize which factor (IP, phone number, requestId) the rate limiting should apply to.
Key security measures include: always using HTTPS, rate limiting OTP requests, validating and sanitizing input, storing API keys securely (environment variables, secrets management), using Helmet for security headers, and writing thorough automated tests to ensure the reliability of your OTP system.
SMS OTP two-factor authentication (2FA) adds critical security by sending One-Time Passwords to users' mobile devices during login or sensitive operations. This comprehensive guide teaches you how to implement production-ready SMS OTP verification using Node.js, the high-performance Fastify framework, and the Vonage Verify API v2. You'll learn security best practices, implement rate limiting, handle errors gracefully, and write automated tests for reliable authentication.
Build a complete backend API with two secure endpoints: one to request an OTP code sent via SMS to a user's phone number in E.164 format, and another to verify the OTP code entered by the user.
What Are You Building with Vonage and Fastify?
What You're Building:
A backend API service built with Node.js and Fastify that:
Problem Solved:
This implementation addresses the need for secure, out-of-band verification commonly used during:
Technologies Used:
dotenv: Module to load environment variables from a.envfile intoprocess.env.@vonage/server-sdk: Official Vonage Node.js SDK for interacting with Vonage APIs.System Architecture:
Your system follows this flow:
/request-otp/verify-otpPrerequisites:
npm install -g @vonage/cli.Technology Versions Used in This Guide:
Note on Vonage Verify API: This guide uses the Vonage Verify v2 API, which supports multiple authentication channels including SMS, Voice, Email, WhatsApp, and Silent Authentication. The API automatically handles OTP generation, delivery, expiration, and verification logic.
Important Security Considerations:
+14155552671for US numbers,+442071234567for UK numbers). The Vonage API expects this international format and may reject improperly formatted numbers. Common format errors: missing+prefix, including spaces or dashes, omitting country code.Sources: Fastify Official Site, Vonage Server SDK npm, Vonage Verify API Documentation, Vonage Verify v2 Fraud Protection, accessed October 2025.
Expected Outcome:
A functional Fastify API service capable of sending and verifying SMS OTPs via Vonage, ready for integration into a larger application. The service will include basic security, error handling, and logging.
How Does SMS OTP 2FA Work?
OTP Generation and Security:
The Vonage Verify API generates cryptographically secure random codes (typically 4–6 digits) that are valid for a limited time window. This approach provides:
Verification Flow:
request_idrequest_idand user-entered code to Vonage0) if the code matches and is still valid, or an error status if it doesn't match, expired, or was already usedSecurity Principles:
How Do You Set Up a Fastify Project for OTP Verification?
Initialize your Node.js project and install Fastify along with necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-vonage-otp cd fastify-vonage-otp2. Initialize npm Project:
This creates a
package.jsonfile with default settings.3. Install Dependencies:
Install Fastify, the Vonage SDK,
dotenvfor environment variables, andpino-prettyfor development logging.fastify: The core web framework.@vonage/server-sdk: The official Vonage Node.js library.dotenv: Loads environment variables from a.envfile.pino-pretty: Makes Fastify's default JSON logs human-readable during development.4. Configure
package.jsonfor Development:Add a
devscript topackage.jsonto run the server with readable logs usingpino-pretty.{ "name": "fastify-vonage-otp", "version": "1.0.0", "description": "", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "node src/server.js | pino-pretty", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@vonage/server-sdk": "^...", "dotenv": "^...", "fastify": "^...", "pino-pretty": "^..." } }(Note: Replace
^...with actual installed versions if needed, butnpm installhandles this.)5. Create Project Structure:
Organize the project for clarity:
mkdir src mkdir src/routes touch src/server.js touch .env touch .gitignoresrc/: Contains the main application code.src/routes/: Will hold our API route definitions.src/server.js: The main entry point for the Fastify application..env: Stores sensitive information like API keys (DO NOT commit this file)..gitignore: Specifies intentionally untracked files that Git should ignore.6. Configure
.gitignore:Add
node_modulesand.envto your.gitignorefile to prevent committing them.7. Set Up Environment Variables:
Open the
.envfile and add your Vonage API credentials and a brand name for the SMS message.Replace
YOUR_API_KEYandYOUR_API_SECRETwith the actual credentials from your Vonage API Dashboard.8. Basic Fastify Server Setup:
Create the initial server configuration in
src/server.js.// src/server.js // Load environment variables early require('dotenv').config(); const fastify = require('fastify')({ logger: true // Enable built-in Pino logger }); // --- Plugin Registration --- // Example: Register formbody parser if needed later for simpler forms // fastify.register(require('@fastify/formbody')); // --- Vonage Client Setup (Placeholder) --- // We will add Vonage initialization here later // --- Route Registration (Placeholder) --- // We will register our OTP routes here // --- Error Handling (Placeholder) --- // We will add custom error handling here // --- Start Server --- const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Explanation:
require('dotenv').config();: Loads variables from.envintoprocess.env. Crucially done before other modules might need them.fastify({ logger: true }): Initializes Fastify with logging enabled. Pino is used by default.fastify.listen(): Starts the server. We listen on0.0.0.0to make it accessible within Docker containers or VMs if needed later.Run
npm run devin your terminal. You should see log output indicating the server is running, likely on port 3000. Stop the server withCtrl+C.How Do You Integrate the Vonage Verify API?
Initialize the Vonage SDK client and make it available within your Fastify application.
1. Initialize Vonage Client:
Update
src/server.jsto create and configure the Vonage client using the environment variables. We'll use Fastify'sdecorateutility to make the client accessible within route handlers viarequest.vonageorfastify.vonage.// src/server.js require('dotenv').config(); const fastify = require('fastify')({ logger: true }); const { Vonage } = require('@vonage/server-sdk'); // Import Vonage // --- Validate Environment Variables --- if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET'); process.exit(1); } // --- Plugin Registration --- // fastify.register(require('@fastify/formbody')); // --- Vonage Client Setup --- const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // Decorate Fastify instance/request with the Vonage client fastify.decorate('vonage', vonage); // --- Route Registration (Placeholder) --- // We will register our OTP routes here // --- Error Handling (Placeholder) --- // We will add custom error handling here // --- Start Server --- const start = async () => { // ... (rest of the start function remains the same) try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Explanation:
Vonageclass from the SDK.Vonageusing the API key and secret loaded from.env.fastify.decorate('vonage', vonage)adds thevonageinstance to Fastify's application context, making it easily accessible in routes and plugins.How Do You Implement the OTP Request and Verification Flow?
Create two API endpoints within a dedicated route file:
POST /request-otp: Initiates the OTP process.POST /verify-otp: Verifies the submitted OTP.1. Create Route File:
Create a new file
src/routes/otp.js.touch src/routes/otp.js2. Define Routes:
Add the route logic to
src/routes/otp.js. Use Fastify's schema validation for request bodies.// src/routes/otp.js async function otpRoutes(fastify, options) { // Schema for the /request-otp endpoint body const requestOtpSchema = { body: { type: 'object', required: ['phoneNumber'], properties: { phoneNumber: { type: 'string', description: 'User phone number in E.164 format (e.g., +14155552671)' } }, additionalProperties: false // Disallow extra fields } }; // Schema for the /verify-otp endpoint body const verifyOtpSchema = { body: { type: 'object', required: ['requestId', 'code'], properties: { requestId: { type: 'string', description: 'The request ID received from /request-otp' }, code: { type: 'string', minLength: 4, maxLength: 6, description: 'The OTP code entered by the user' } }, additionalProperties: false // Disallow extra fields } }; // --- Endpoint: Request OTP --- fastify.post('/request-otp', { schema: requestOtpSchema }, async (request, reply) => { const { phoneNumber } = request.body; const vonage = fastify.vonage; // Access Vonage client via decorator const brand = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Use brand from .env or default request.log.info(`Requesting OTP for phone number: ${phoneNumber}`); try { const response = await vonage.verify.start({ number: phoneNumber, brand: brand, // code_length: '6' // Optional: Uncomment to request a 6-digit code (default is 4) // workflow_id: 6 // Optional: Controls delivery method. Default (1): SMS only. 6: SMS → Voice → Voice for better delivery. }); if (response.status === '0') { request.log.info(`OTP request successful, requestId: ${response.request_id}`); return reply.send({ success: true, requestId: response.request_id }); } else { // Non-zero status indicates an error from Vonage before sending request.log.error(`Vonage verify start error for ${phoneNumber}: Status ${response.status} – ${response.error_text}`); return reply.status(500).send({ success: false, message: response.error_text || 'Failed to initiate OTP verification.' }); } } catch (error) { request.log.error({ err: error, phoneNumber }, 'Error calling Vonage verify start API'); // Handle network errors or SDK issues return reply.status(500).send({ success: false, message: 'An internal server error occurred while requesting OTP.' }); } }); // --- Endpoint: Verify OTP --- fastify.post('/verify-otp', { schema: verifyOtpSchema }, async (request, reply) => { const { requestId, code } = request.body; const vonage = fastify.vonage; // Access Vonage client request.log.info(`Verifying OTP for requestId: ${requestId}`); try { const response = await vonage.verify.check(requestId, code); if (response.status === '0') { // Status '0' means successful verification request.log.info(`OTP verification successful for requestId: ${requestId}`); return reply.send({ success: true, message: 'OTP verified successfully.' }); } else { // Handle specific Vonage error statuses request.log.warn(`OTP verification failed for requestId ${requestId}: Status ${response.status} – ${response.error_text}`); let userMessage = 'OTP verification failed.'; let statusCode = 400; // Bad Request by default for verification failures // Customize messages based on common statuses (refer to Vonage docs for full list) switch (response.status) { case '6': // The code provided does not match the expected value userMessage = 'Invalid or incorrect OTP code provided.'; break; case '16': // The request specified by the request_id has already been verified userMessage = 'This request has already been verified.'; statusCode = 409; // Conflict break; case '17': // The wrong code was provided too many times userMessage = 'Too many incorrect attempts. Request a new OTP.'; break; case '101': // No response found (e.g., expired request_id) userMessage = 'Verification request not found or expired. Request a new OTP.'; statusCode = 404; // Not Found break; default: userMessage = response.error_text || 'OTP verification failed.'; } return reply.status(statusCode).send({ success: false, message: userMessage, errorCode: response.status }); } } catch (error) { // Handle potential SDK errors or network issues during check request.log.error({ err: error, requestId }, 'Error calling Vonage verify check API'); // Check if the error is a Vonage specific error structure (might vary by SDK version) // Note: The structure of Vonage SDK errors (`error.body`) can change between versions. Always consult the documentation for the specific SDK version you are using. if (error.body && error.body.status && error.body.error_text) { request.log.error(`Vonage API Error during check: Status ${error.body.status} – ${error.body.error_text}`); return reply.status(error.statusCode || 500).send({ success: false, message: error.body.error_text || 'An internal error occurred during OTP verification.' }); } return reply.status(500).send({ success: false, message: 'An internal server error occurred during OTP verification.' }); } }); } module.exports = otpRoutes;Explanation:
otpRoutes(fastify, options): Standard Fastify plugin structure.requestOtpSchema,verifyOtpSchema): Define the expected structure and types for the request bodies. Fastify automatically validates incoming requests against these schemas and returns a 400 Bad Request error if validation fails.additionalProperties: falseprevents unexpected fields./request-otp:phoneNumberfrom the validatedrequest.body.vonageclient.vonage.verify.start()with the phone number and brand name.response.status. Astatusof'0'means Vonage accepted the request and will attempt to send the SMS. Other statuses mean an error occurred before sending (e.g., invalid number format, throttling).requestIdon success, which the client needs for the verification step.try...catchfor network/SDK errors./verify-otp:requestIdandcodefrom the validatedrequest.body.vonage.verify.check()with therequestIdand the user-providedcode.response.status:'0'means the code was correct.try...catchfor network/SDK errors during the check.For a complete list of Vonage Verify API error status codes, see the Vonage Verify API Error Codes Reference.
3. Register Routes in Server:
Register these routes in
src/server.js.// src/server.js require('dotenv').config(); const fastify = require('fastify')({ logger: true }); const { Vonage } = require('@vonage/server-sdk'); const otpRoutes = require('./routes/otp'); // Import the routes // --- Validate Environment Variables --- if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET'); process.exit(1); } // --- Plugin Registration --- // fastify.register(require('@fastify/formbody')); // --- Vonage Client Setup --- const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); fastify.decorate('vonage', vonage); // --- Route Registration --- fastify.register(otpRoutes, { prefix: '/api/v1' }); // Register OTP routes under /api/v1 prefix // --- Error Handling (Placeholder) --- // We will add custom error handling here // --- Start Server --- const start = async () => { // ... (start function) try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Explanation:
otpRoutesfunction.fastify.register(otpRoutes, { prefix: '/api/v1' }): Registers all routes defined inotp.jsunder the/api/v1path prefix (e.g.,/api/v1/request-otp). Using a prefix is good practice for API versioning.At this point, you have the core API functionality. Test it using
curlor a tool like Postman after starting the server (npm run dev).Testing with
curl:Replace
+1XXXXXXXXXXwith a real phone number you can receive SMS on (use E.164 format).Request OTP:
curl -X POST http://localhost:3000/api/v1/request-otp \ -H 'Content-Type: application/json' \ -d '{"phoneNumber": "+1XXXXXXXXXX"}'Expected Response (Success):
{"success":true,"requestId":"YOUR_UNIQUE_REQUEST_ID"}You should receive an SMS with a 4-digit code (by default).
Verify OTP: Replace
YOUR_UNIQUE_REQUEST_IDwith the ID from the previous step andYOUR_OTP_CODEwith the code from the SMS.curl -X POST http://localhost:3000/api/v1/verify-otp \ -H 'Content-Type: application/json' \ -d '{"requestId": "YOUR_UNIQUE_REQUEST_ID", "code": "YOUR_OTP_CODE"}'Expected Response (Success):
{"success":true,"message":"OTP verified successfully."}Expected Response (Incorrect Code):
{"success":false,"message":"Invalid or incorrect OTP code provided.","errorCode":"6"}(Note: The HTTP status code will be 400 for incorrect code)
Common Testing Issues:
.envfile has correct credentialsHow Do You Add Error Handling and Logging?
Fastify's built-in logger (Pino) is already active. Add a centralized error handler to catch unhandled exceptions and format error responses consistently.
1. Add Custom Error Handler:
Update
src/server.jsto includesetErrorHandler.// src/server.js require('dotenv').config(); const fastify = require('fastify')({ logger: true }); const { Vonage } = require('@vonage/server-sdk'); const otpRoutes = require('./routes/otp'); // --- Validate Environment Variables --- if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET'); process.exit(1); } // --- Plugin Registration --- // fastify.register(require('@fastify/formbody')); // --- Vonage Client Setup --- const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); fastify.decorate('vonage', vonage); // --- Route Registration --- fastify.register(otpRoutes, { prefix: '/api/v1' }); // --- Error Handling --- fastify.setErrorHandler(function (error, request, reply) { // Log the error request.log.error(error); // Check if it's a validation error from Fastify schema check if (error.validation) { reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation, // Provides details on which fields failed }); return; } // Check for specific error types you might throw or receive // Example: if (error instanceof MyCustomError) { ... } // Default fallback for other errors // Use the status code from the error if available (e.g., from Vonage errors handled in routes) const statusCode = error.statusCode || 500; reply.status(statusCode).send({ success: false, message: error.message || 'An unexpected internal server error occurred.', // Avoid leaking stack traces in production environments ...(process.env.NODE_ENV !== 'production' && { stack: error.stack }) }); }); // --- Start Server --- const start = async () => { // ... (start function) try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Explanation:
fastify.setErrorHandler: Registers a function to handle errors that occur during request processing after the initial routing but before a reply is sent, or errors explicitly passed toreply.send(error).request.log.error(error).error.validation) and returns a structured 400 response.statusCodeandmessageif available (falling back to 500).NODE_ENVis not 'production' to avoid leaking sensitive information.Logging Best Practices:
request.log.info,request.log.error,request.log.warn) to provide context about the OTP flow.npm run dev),pino-prettyformats these JSON logs nicely. In production, pipe the raw JSON logs to a log management system (e.g., Datadog, ELK stack, Splunk) for analysis and alerting.Key Log Queries for Monitoring:
status:"failed" AND requestId:*– Identify suspicious patternsmessage:"Rate limit exceeded"– Adjust limits if legitimate users are blockederror_text:* AND status:!0– Track API reliabilityphoneNumber:"+9*"– Monitor international SMS costsWhat Security Features Does Your OTP System Need?
Security is paramount for an authentication mechanism.
1. Rate Limiting:
Protect against brute-force attacks on both requesting and verifying OTPs. Learn more about implementing 2FA security best practices for SMS authentication.
Install Rate Limiter Plugin:
Register and Configure: Add this to the "Plugin Registration" section in
src/server.js.// src/server.js // ... other imports require('dotenv').config(); // Ensure this is at the top const fastify = require('fastify')({ logger: true }); const { Vonage } = require('@vonage/server-sdk'); const otpRoutes = require('./routes/otp'); // --- Validate Environment Variables --- if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('ERROR: Missing required environment variables VONAGE_API_KEY or VONAGE_API_SECRET'); process.exit(1); } // --- Plugin Registration --- fastify.register(require('@fastify/rate-limit'), { max: 100, // Max requests per timeWindow per IP (adjust globally) timeWindow: '1 minute', // Optional: customize error response errorResponseBuilder: function (req, context) { return { success: false, message: `Rate limit exceeded, retry after ${context.after}`, code: 'RATE_LIMIT_EXCEEDED', retryAfter: context.ttl, // Time-to-live seconds for the ban } }, // Optional: Key generator (e.g., use userId if authenticated) // keyGenerator: function (req) { return req.headers['x-real-ip'] || req.ip } // Default is IP }); // Register Helmet here too (see step 4 below) // fastify.register(require('@fastify/helmet')); // --- Vonage Client Setup --- const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); fastify.decorate('vonage', vonage); // --- Route Registration --- fastify.register(otpRoutes, { prefix: '/api/v1' }); // --- Error Handling --- // ... (setErrorHandler from Section 4) fastify.setErrorHandler(function (error, request, reply) { request.log.error(error); if (error.validation) { reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation }); return; } const statusCode = error.statusCode || 500; reply.status(statusCode).send({ success: false, message: error.message || 'An unexpected internal server error occurred.', ...(process.env.NODE_ENV !== 'production' && { stack: error.stack }) }); }); // --- Start Server --- const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Recommended Rate Limit Configuration:
/request-otp/verify-otpApply Specific Limits (Optional but Recommended): You can override the global settings within specific route options in
src/routes/otp.js. It's wise to have stricter limits on OTP requests than general API usage.// src/routes/otp.js // ... schemas ... async function otpRoutes(fastify, options) { // --- Endpoint: Request OTP --- fastify.post('/request-otp', { schema: requestOtpSchema, config: { // Add rate limit config specific to this route rateLimit: { max: 5, // Allow only 5 requests per hour per key timeWindow: '1 hour', // WARNING: Using `phoneNumber` directly in `keyGenerator` can block legitimate users sharing IPs (e.g., behind NAT). // This approach is generally safer *only if* user authentication happens *before* the OTP request. // Otherwise, IP-based limiting (`req.ip`) is usually the safer default starting point. Combining IP and phone number is another option. // keyGenerator: function (req) { return req.body.phoneNumber || req.ip } } } }, async (request, reply) => { // ... route handler logic ... }); // --- Endpoint: Verify OTP --- fastify.post('/verify-otp', { schema: verifyOtpSchema, config: { // Add rate limit config specific to this route rateLimit: { max: 10, // Allow slightly more verification attempts per key timeWindow: '15 minutes' // Keying by requestId might be useful here, combined with IP // keyGenerator: function (req) { return `${req.ip}-${req.body.requestId}` } } } }, async (request, reply) => { // ... route handler logic ... }); } module.exports = otpRoutes;Explanation:
@fastify/rate-limit: Provides robust rate limiting based on IP address by default.max,timeWindow: Control how many requests are allowed within a specific timeframe.errorResponseBuilder: Customizes the response when the limit is hit.keyGenerator: Allows using factors other than IP (like phone number,requestId, or authenticated user ID) for more granular limiting. Use with caution, as poorly chosen keys can block legitimate users. IP-based is often the simplest starting point.config.rateLimitoverrides the global settings for finer control.2. Input Validation and Sanitization:
google-libphonenumberon the backend before sending to Vonage provides earlier, more specific feedback to the user about formatting issues.Phone Number Validation Example:
// Install: npm install google-libphonenumber const { PhoneNumberUtil } = require('google-libphonenumber'); const phoneUtil = PhoneNumberUtil.getInstance(); function validatePhoneNumber(phoneNumber) { try { const parsed = phoneUtil.parse(phoneNumber); return phoneUtil.isValidNumber(parsed); } catch (error) { return false; } } // Use in route: if (!validatePhoneNumber(phoneNumber)) { return reply.status(400).send({ success: false, message: 'Invalid phone number format. Use E.164 format (e.g., +14155552671)' }); }3. Secure Handling of Secrets:
.envand loaded viadotenv..gitignore: The.envfile is in.gitignoreto prevent accidental commits..envfiles directly to production servers.4. Other Considerations:
@fastify/helmetto set various security-related HTTP headers (likeX-Frame-Options,Strict-Transport-Security).src/server.jswithin the "Plugin Registration" section:// src/server.js - Ensure this is registered in the Plugin Registration section fastify.register(require('@fastify/helmet'));How Do You Test Your SMS OTP API?
Write automated tests to ensure reliability. Use
tap, Fastify's default test runner.1. Install
tapas a Dev Dependency:2. Update
testscript inpackage.json:{ "name": "fastify-vonage-otp", "version": "1.0.0", "description": "", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "node src/server.js | pino-pretty", "test": "tap \"test/**/*.test.js\"" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@fastify/helmet": "^...", "@fastify/rate-limit": "^...", "@vonage/server-sdk": "^...", "dotenv": "^...", "fastify": "^..." }, "devDependencies": { "pino-pretty": "^...", "tap": "^..." } }(Note: Added
@fastify/helmet,@fastify/rate-limitto dependencies andtap,pino-prettyto devDependencies based on previous steps. Ensure versions match your installation.)3. Create Test File Structure:
mkdir test mkdir test/routes touch test/routes/otp.test.js4. Write Comprehensive Tests:
Here's an example testing multiple scenarios for both endpoints, mocking the Vonage API call.
// test/routes/otp.test.js 'use strict' const { test } = require('tap') // Placeholder for building the app - replace with your actual helper async function build(t, mockVonage) { const fastify = require('fastify')(); // Mock the decorator if mockVonage is provided if (mockVonage) { fastify.decorate('vonage', mockVonage); } else { // Provide a default mock if none is passed, to avoid errors fastify.decorate('vonage', { verify: { start: async () => {}, check: async () => {} } }); } // Register routes, error handlers etc. as in server.js const otpRoutes = require('../../src/routes/otp'); fastify.register(otpRoutes, { prefix: '/api/v1' }); // Add setErrorHandler from server.js fastify.setErrorHandler(function (error, request, reply) { request.log.error(error); if (error.validation) { reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation }); return; } const statusCode = error.statusCode || 500; reply.status(statusCode).send({ success: false, message: error.message || 'An unexpected internal server error occurred.', }); }); await fastify.ready(); t.teardown(() => fastify.close()); return fastify; } test('POST /api/v1/request-otp - success', async (t) => { const mockVonage = { verify: { start: async (options) => { t.equal(options.number, '+12345678900', 'should pass correct phone number'); t.ok(options.brand, 'should pass a brand name'); return { status: '0', request_id: 'mock-request-id-123' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/request-otp', payload: { phoneNumber: '+12345678900' } }); t.equal(res.statusCode, 200, 'returns a status code of 200'); const payload = JSON.parse(res.payload); t.ok(payload.success, 'success field should be true'); t.equal(payload.requestId, 'mock-request-id-123', 'returns the correct request ID'); }); test('POST /api/v1/request-otp - validation failure (missing phone)', async (t) => { const app = await build(t); const res = await app.inject({ method: 'POST', url: '/api/v1/request-otp', payload: { /* Missing phoneNumber */ } }); t.equal(res.statusCode, 400, 'returns a status code of 400 for validation error'); const payload = JSON.parse(res.payload); t.notOk(payload.success, 'success field should be false'); t.ok(payload.message.toLowerCase().includes('validation'), 'error message indicates validation failure'); }); test('POST /api/v1/request-otp - Vonage error', async (t) => { const mockVonage = { verify: { start: async () => { return { status: '3', error_text: 'Invalid credentials' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/request-otp', payload: { phoneNumber: '+12345678900' } }); t.equal(res.statusCode, 500, 'returns 500 for Vonage error'); const payload = JSON.parse(res.payload); t.notOk(payload.success, 'success should be false'); t.ok(payload.message.includes('Invalid credentials'), 'includes Vonage error message'); }); test('POST /api/v1/verify-otp - success', async (t) => { const mockVonage = { verify: { check: async (requestId, code) => { t.equal(requestId, 'test-request-id', 'should pass correct request ID'); t.equal(code, '1234', 'should pass correct code'); return { status: '0' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/verify-otp', payload: { requestId: 'test-request-id', code: '1234' } }); t.equal(res.statusCode, 200, 'returns 200 for successful verification'); const payload = JSON.parse(res.payload); t.ok(payload.success, 'success should be true'); t.ok(payload.message.includes('verified'), 'message confirms verification'); }); test('POST /api/v1/verify-otp - incorrect code', async (t) => { const mockVonage = { verify: { check: async () => { return { status: '6', error_text: 'The code provided does not match' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/verify-otp', payload: { requestId: 'test-request-id', code: '9999' } }); t.equal(res.statusCode, 400, 'returns 400 for incorrect code'); const payload = JSON.parse(res.payload); t.notOk(payload.success, 'success should be false'); t.equal(payload.errorCode, '6', 'includes correct error code'); }); test('POST /api/v1/verify-otp - expired request', async (t) => { const mockVonage = { verify: { check: async () => { return { status: '101', error_text: 'No response found' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/verify-otp', payload: { requestId: 'expired-request-id', code: '1234' } }); t.equal(res.statusCode, 404, 'returns 404 for expired request'); const payload = JSON.parse(res.payload); t.notOk(payload.success, 'success should be false'); t.ok(payload.message.toLowerCase().includes('expired'), 'message indicates expiration'); }); test('POST /api/v1/verify-otp - already verified', async (t) => { const mockVonage = { verify: { check: async () => { return { status: '16', error_text: 'Request already verified' }; } } }; const app = await build(t, mockVonage); const res = await app.inject({ method: 'POST', url: '/api/v1/verify-otp', payload: { requestId: 'already-verified-id', code: '1234' } }); t.equal(res.statusCode, 409, 'returns 409 Conflict for already verified'); const payload = JSON.parse(res.payload); t.notOk(payload.success, 'success should be false'); t.ok(payload.message.toLowerCase().includes('already'), 'message indicates already verified'); });5. Create Test Helper (Conceptual):
A file like
test/helper.jswould typically contain a function to build and configure the Fastify app instance for testing, allowing injection of mocks and handling teardown. Creating a full helper is beyond the scope of this guide, but the test file structure assumes its existence.Run tests using
npm test. Add comprehensive tests covering success paths, error paths (Vonage errors, validation errors), and security features like rate limiting.Integration Testing with Vonage:
For integration tests with real Vonage test numbers:
+15005550) that don't send real SMS+15005550006– Vonage treats this as valid but doesn't deliver SMStest/integration/) to avoid incurring costs during developmentWhat Should You Consider When Deploying to Production?
Follow these critical best practices when deploying your SMS OTP service to production:
Environment Variables and Secrets Management:
.envfiles to production servers. Use your hosting platform's environment variable management (AWS Systems Manager, Heroku Config Vars, Google Secret Manager, Azure Key Vault).HTTPS and Transport Security:
@fastify/helmetplugin (covered in Section 5) sets important security headers includingStrict-Transport-Security(HSTS).Logging and Monitoring:
In production, pipe Fastify's JSON logs to a log aggregation service (Datadog, ELK Stack, Splunk, Papertrail) for analysis and alerting.
Key Metrics to Monitor:
Alert Patterns:
Rate Limiting Tuning:
Testing Before Production:
Disaster Recovery and Failover:
/healthendpoints that verify Vonage API connectivityCompliance and Privacy:
Ensure your OTP implementation complies with telecommunications regulations:
Implementation Checklist:
Cost Management:
Sources: Fastify Production Best Practices, OWASP Authentication Cheat Sheet, Vonage API Security Best Practices, accessed October 2025.
Frequently Asked Questions About SMS OTP 2FA
How do I implement OTP verification in Node.js?
Implement OTP verification in Node.js by using the Vonage Verify API with Fastify. Install the
@vonage/server-sdk, create routes for sending OTP codes via/request-otp, and verify user-submitted tokens via/verify-otp. Usevonage.verify.start()to generate and send OTP codes, thenvonage.verify.check()to validate user input against the codes stored by Vonage.What is the Vonage Verify API?
The Vonage Verify API handles OTP generation, SMS delivery, and token verification for two-factor authentication. It automatically generates cryptographically secure random codes (4–6 digits), sends them via SMS, voice, email, or WhatsApp, and validates user-submitted tokens within configurable timeout windows (5–10 minutes by default).
Why use Fastify for SMS authentication?
Use Fastify for SMS authentication because it provides 6x faster performance than Express with built-in schema validation via JSON Schema and a robust plugin ecosystem. Fastify v5's native async/await support and low overhead make it ideal for high-throughput authentication services handling thousands of concurrent OTP requests.
What Node.js version is required for this tutorial?
This tutorial requires Node.js v20 or later. Fastify v5 specifically requires Node.js v20+ for native async hooks and performance optimizations. The
@vonage/server-sdkv3.24+ works with Node.js v14+ but performs best on v20+.How long do Vonage OTP codes remain valid?
Vonage OTP codes remain valid for 5–10 minutes by default, depending on the workflow configuration. You can configure expiration by setting the workflow_id parameter when calling
verify.start(). Expired codes cannot be verified and require users to request a new OTP.How do I secure my Vonage API credentials?
Store Vonage API keys in environment variables via
.envfiles (never commit to version control). Use live keys (starting withlive_) for production and test keys for development. Consider IP whitelisting in the Vonage dashboard if your server uses static IPs, and monitor usage for anomalies via the dashboard.What is E.164 phone number format?
E.164 is the international phone number format used by telecommunications systems. It starts with a
+followed by the country code and national number with no spaces or special characters (e.g.,+14155552671for US,+442071234567for UK). The Vonage API requires E.164 format and may reject improperly formatted numbers.How do I implement rate limiting for OTP endpoints?
Implement rate limiting using
@fastify/rate-limitplugin. Configure stricter limits for OTP request endpoints (3–5 requests per hour per IP) compared to verification endpoints (5–10 per 15 minutes). This prevents SMS flooding, reduces costs, and protects against brute-force attacks while allowing legitimate user typos.Can I use Vonage OTP for login authentication?
Yes, Vonage OTP is ideal for login authentication as a second factor beyond passwords. After users enter their username and password, send an OTP to their registered phone number via
/request-otp, then verify the code they enter via/verify-otp. This significantly enhances account security against credential theft. For more authentication patterns, see our guide on implementing two-factor authentication in Node.js.How much does each Vonage OTP request cost?
Each Vonage OTP request costs approximately $0.06–$0.10 USD depending on the destination country. Implement proper rate limiting, phone number validation, and consider adding CAPTCHA before OTP requests to prevent malicious actors from generating excessive verification requests that increase costs.
Conclusion
You've built a production-ready SMS OTP verification system using Fastify and the Vonage Verify API. This implementation includes:
Next Steps:
Integrate this API into your application with these specific patterns:
Frontend Integration:
// Request OTP during user registration async function requestOTP(phoneNumber) { const response = await fetch('/api/v1/request-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber }) }); const data = await response.json(); return data.requestId; // Store this for verification } // Verify OTP when user submits code async function verifyOTP(requestId, code) { const response = await fetch('/api/v1/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestId, code }) }); return await response.json(); }User Account Association:
requestIdto user sessions during registration/login flowsMulti-Channel Support:
Scaling Strategies:
Additional Resources:
This guide provides a solid foundation for implementing secure SMS-based two-factor authentication in your Node.js applications.