code examples
code examples
How to Implement SMS OTP Authentication in Next.js with Vonage Verify API V2 (2025 Guide)
Learn how to add SMS OTP and two-factor authentication in Next.js using Vonage Verify API V2 with Node.js/Express. Complete code examples, security best practices, and production deployment guide.
Build OTP/2FA with Vonage Verify API V2, Node.js & Next.js
Learn how to implement secure SMS OTP authentication and two-factor authentication (2FA) in your Next.js application using Vonage's Verify API V2 with a Node.js/Express backend. This comprehensive tutorial covers building a production-ready OTP authentication system with phone verification, security best practices, rate limiting, error handling, and deployment strategies – everything you need to add SMS verification and two-factor authentication to protect user accounts.
Project Overview and Goals
What You'll Build:
Create a simple application demonstrating a complete SMS OTP authentication flow:
- Enter your phone number on a Next.js frontend.
- The frontend sends the number to a Node.js/Express backend API.
- The backend uses the Vonage Verify V2 API to initiate an OTP request (sending a code via SMS or voice call to your phone).
- Receive the code and enter it into the Next.js frontend.
- The frontend sends the code and a unique request identifier back to the backend API. (Note: For simplicity, this guide sends the request ID to the client. Section 6 discusses a more secure server-side session approach.)
- The backend uses the Vonage Verify V2 API to check the code's validity.
- The frontend displays a success or failure message.
Problem Solved:
Add a critical security layer beyond traditional passwords. By requiring users to possess their phone to receive and enter a time-sensitive code, you significantly reduce the risk of unauthorized account access – even if passwords are compromised through phishing, credential stuffing, or data breaches.
Technologies Used:
- Frontend: Next.js (React Framework) – Modern features, performance optimizations, and excellent developer experience for building user interfaces. Uses the App Router.
- Backend: Node.js with Express – Robust and widely-adopted combination for building efficient and scalable APIs. This Express server runs separately from the Next.js application.
- Authentication Service: Vonage Verify API V2 – Managed, reliable, and global service for sending and verifying OTPs across multiple channels (SMS, Voice), simplifying the complex infrastructure required for phone verification and 2FA.
- Vonage Node SDK (
@vonage/server-sdk): Simplifies interaction with the Vonage API from your Node.js backend. - Environment Management:
dotenv– Securely manage API keys and configuration variables. - API Communication: Standard
fetchAPI in Next.js,express.json()andcorsmiddleware in Express.
System Architecture:
+-----------------+ +-------------------+ +-------------------+ +-----------------+
| User Browser | ---> | Next.js Frontend | ---> | Node.js/Express | ---> | Vonage Verify |
| (Input Phone/ | | (React Components,| | Backend API | | API V2 |
| Input Code) | | API Calls) | | (/request, /check)| | (Send/Check OTP)|
+-----------------+ +--------^----------+ +--------^----------+ +-----------------+
| | |
| Success/Error | Success/Error + ReqID* | Success/Error
+------------------------+------------------------+
*Note: Sending ReqID to client is less secure. See Sec 6.Prerequisites:
- Node.js (v20 or later recommended; v18 reached EOL March 27, 2025) and npm/yarn installed.
- A Vonage API account. You can sign up for free credit.
- You will need your API Key and API Secret from the Vonage API Dashboard.
- Basic understanding of JavaScript, React, Next.js, and Node.js/Express.
- A text editor (like VS Code) and a terminal.
Expected Outcome:
By the end of this guide, you will have a functional application with SMS OTP authentication. Users can request an OTP, receive it on their phone, enter it, and have it verified. You will also understand the principles of integrating Vonage Verify V2 for phone verification, handling potential errors, and implementing security best practices.
How to Set Up Your Next.js and Node.js Project
Create separate directories for the frontend and backend for clarity.
1.1. Create Project Structure:
Open your terminal and create a main project directory, then navigate into it.
mkdir vonage-otp-app
cd vonage-otp-appCreate directories for the frontend and backend:
mkdir frontend backend1.2. Backend Setup (Node.js/Express):
Navigate into the backend directory:
cd backendInitialize the Node.js project:
npm init -yThis creates a package.json file.
Install necessary dependencies:
express: The web framework.@vonage/server-sdk: The official Vonage Node.js SDK (V3+ for Verify V2).dotenv: To load environment variables from a.envfile.cors: To enable Cross-Origin Resource Sharing (needed because frontend and backend run on different ports during development).
npm install express @vonage/server-sdk dotenv corsCreate the main server file:
touch server.jsCreate a .env file to store sensitive credentials:
touch .envOpen the .env file and add your Vonage API credentials and a brand name. IMPORTANT: Replace the placeholder values (YOUR_API_KEY, YOUR_API_SECRET) with your actual credentials found on the Vonage Dashboard.
# backend/.env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=MyAppName # Brand name shown in SMS message (max 18 chars, alphanumeric recommended)
PORT=5001 # Port for the backend serverExplanation of .env Variables:
VONAGE_API_KEY: Your unique identifier for accessing the Vonage API. Found on your Vonage Dashboard.VONAGE_API_SECRET: Your secret key for authenticating API requests. Found on your Vonage Dashboard. Treat this like a password.VONAGE_BRAND_NAME: The name displayed as the sender in the OTP message. Maximum 18 characters. Helps users identify the origin of the code. Must match regex pattern^[^\/{}:$]*$for security reasons.PORT: The network port your Express server will listen on.
IMPORTANT: Secure Your Credentials
Create a .gitignore file in the backend directory to prevent accidentally committing your secrets:
touch .gitignoreAdd the following lines to backend/.gitignore:
# backend/.gitignore
node_modules/
.env1.3. Frontend Setup (Next.js):
Navigate back to the root vonage-otp-app directory and then into the frontend directory:
cd ../frontendCreate a new Next.js application using the App Router:
npx create-next-app@latest . --typescript=no --eslint=yes --tailwind=no --src-dir=no --app=yes --import-alias="@/*"(Answer prompts as needed. Choose no TypeScript/Tailwind/src dir for simplicity here, but feel free to adjust).
This command scaffolds a new Next.js project in the current directory (.).
The frontend doesn't require additional dependencies for this core functionality.
Add Frontend .gitignore:
Ensure your frontend/.gitignore file (usually created by create-next-app) includes lines to ignore local environment files and build artifacts:
# frontend/.gitignore (ensure these lines exist or add them)
node_modules/
.next/
out/
.env*.localHow to Build the Vonage Verify API Backend with Node.js
Build the Express API endpoints that will interact with the Vonage Verify V2 API for SMS OTP authentication.
2.1. Basic Express Server Setup:
Open the backend/server.js file and add the following initial setup:
// backend/server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const cors = require('cors');
const { Vonage } = require('@vonage/server-sdk'); // Vonage SDK V3+
const app = express();
const port = process.env.PORT || 5001;
// --- Vonage Client Initialization ---
// Ensure API Key and Secret are loaded
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('Error: VONAGE_API_KEY and VONAGE_API_SECRET must be set in .env file');
process.exit(1); // Exit if credentials are missing
}
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
});
// --- Middleware ---
app.use(cors()); // Enable CORS for requests from the frontend (adjust in production)
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies
// --- API Routes (To be added below) ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', message: 'Backend is running' });
}); // Basic health check endpoint
// --- Start Server ---
app.listen(port, () => {
console.log(`Backend server listening at http://localhost:${port}`);
});Explanation:
require('dotenv').config(): Loads variables from the.envfile intoprocess.env. Crucial for accessing credentials.new Vonage(...): Initializes the Vonage SDK client with your credentials.app.use(cors()): Allows requests from your Next.js frontend (running on a different port, e.g., 3000) to reach the backend (running on 5001). For production, configure CORS more restrictively.app.use(express.json()): Enables the server to understand incoming request bodies formatted as JSON./healthroute: A simple endpoint to check if the server is running.
2.2. Implementing the /api/request-verification Endpoint:
This endpoint receives a phone number from the frontend and triggers the Vonage OTP request using Verify V2.
Add the following route before the app.listen call in backend/server.js:
// backend/server.js
// ... (previous setup code) ...
// --- API Routes ---
// Endpoint to request a verification code (Verify V2)
app.post('/api/request-verification', async (req, res) => {
const { phoneNumber } = req.body;
// Basic validation (more robust validation recommended in production)
if (!phoneNumber) {
return res.status(400).json({ error: 'Phone number is required.' });
}
console.log(`Requesting verification for: ${phoneNumber}`);
try {
const result = await vonage.verify.start({
number: phoneNumber,
brand: process.env.VONAGE_BRAND_NAME || 'MyApp',
code_length: 6 // Request a 6-digit code (default is 4 for V1, 6 is common)
// workflow_id: Can specify SMS/Voice/Silent Auth etc. if needed
});
console.log('Verification request sent successfully. Request ID:', result.request_id);
// ** SECURITY WARNING **
// Sending the request_id back to the client is simpler for this demo,
// but LESS SECURE. The client could potentially misuse it.
// A better approach (see Section 6) is to store the request_id
// in a server-side session associated with the user.
return res.status(200).json({ requestId: result.request_id });
} catch (error) {
console.error('Vonage API Error:', error);
// Try to extract a meaningful error message
let errorMessage = 'Failed to initiate verification.';
let statusCode = 500;
if (error.response && error.response.data) {
// Handle errors structured according to Vonage API V2 spec
errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
if (error.response.data.type && error.response.data.type.includes('validation')) {
statusCode = 400; // Bad request (e.g., invalid number)
}
if (error.response.status === 429) {
statusCode = 429; // Throttling
errorMessage = 'Too many requests. Please try again later.';
} else {
statusCode = error.response.status || 500;
}
console.error('Vonage Error Details:', error.response.data);
} else if (error.message) {
errorMessage = error.message;
}
return res.status(statusCode).json({ error: errorMessage });
}
});
// ... (health check route and app.listen) ...Explanation:
- The route listens for POST requests at
/api/request-verification. It's nowasync. - It extracts the
phoneNumberfrom the JSON request body (req.body). - Basic validation checks if the phone number exists.
vonage.verify.start()(Verify V2 method) is called usingawait:number: The user's phone number (should be in E.164 format ideally, e.g., +14155552671).brand: The sender name from your.envfile.code_length: Set to 6 for a 6-digit OTP.
- Error Handling: A
try...catchblock handles potential errors during the API call. Vonage SDK V3+ throws errors on failure. We inspect theerrorobject (oftenerror.response.datafor API errors) to provide a more specific message and status code. requestIdHandling (Security Note): Upon success, therequest_idis returned. The code includes a prominent warning explaining that sending this to the client is less secure and refers to Section 6 for the recommended server-side session approach.
2.3. Implementing the /api/check-verification Endpoint:
This endpoint receives the requestId (obtained from the previous step) and the OTP code entered by the user, then checks their validity with Vonage Verify V2.
Add the following route before the app.listen call in backend/server.js:
// backend/server.js
// ... (previous setup code and /api/request-verification route) ...
// Endpoint to check the verification code (Verify V2)
app.post('/api/check-verification', async (req, res) => {
const { requestId, code } = req.body;
// Basic validation
if (!requestId || !code) {
return res.status(400).json({ error: 'Request ID and code are required.' });
}
if (code.length !== 6 || !/^\d+$/.test(code)) { // Basic check for 6 digits
return res.status(400).json({ error: 'Invalid code format. Must be 6 digits.' });
}
console.log(`Checking verification for Request ID: ${requestId} with Code: ${code}`);
try {
const result = await vonage.verify.check({
request_id: requestId,
code: code
});
// Verify V2 'check' returns status 200 OK with {request_id, status: "completed"}
// when the code is correct. Per Vonage Verify V2 API spec (as of 2025).
console.log('Verification successful for Request ID:', requestId);
// In a real app, you would now:
// 1. Mark the user's 2FA as verified for this session (if using sessions).
// 2. Potentially link this device/verification if needed.
// 3. Grant access to the protected resource/complete login.
return res.status(200).json({ message: 'Verification successful.' });
} catch (error) {
console.error('Vonage API Check Error:', error);
let errorMessage = 'Failed to check verification code.';
let statusCode = 500;
if (error.response && error.response.data) {
// Handle specific V2 check errors
errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
statusCode = error.response.status || 500; // Use status from Vonage response
if (statusCode === 400) { // Often incorrect code
errorMessage = 'The code you entered was incorrect or has expired. Please try again.';
} else if (statusCode === 410) { // Gone - request expired or already used
errorMessage = 'Verification request not found, has expired, or already used. Please request a new code.';
} else if (statusCode === 409) { // Conflict - concurrent verification (Vonage-specific)
errorMessage = 'Concurrent verifications to the same number are not allowed. Please wait for the current verification to complete.';
} else if (statusCode === 429) { // Throttling on check attempts
errorMessage = 'Too many check attempts. Please request a new code.';
}
console.error('Vonage Check Error Details:', error.response.data);
} else if (error.message) {
errorMessage = error.message;
}
return res.status(statusCode).json({ error: errorMessage });
}
});
// ... (health check route and app.listen) ...Explanation:
- The route listens for POST requests at
/api/check-verification. It'sasync. - It extracts the
requestIdand thecodeentered by the user. - Basic validation checks if both fields exist and if the code looks like a 6-digit number.
vonage.verify.check()(Verify V2 method) is called usingawait:request_id: The ID of the verification attempt.code: The OTP code entered by the user.
- Error Handling & Status Check:
- Verify V2's
checkmethod succeeds by returning a 200 OK status without throwing an error. If theawaitcompletes without error, the code is valid. - If the code is invalid, expired, or the request ID doesn't exist, the SDK throws an error. The
catchblock inspectserror.responseto determine the cause (e.g., status codes 400, 410, 429) and provides appropriate user feedback.
- Verify V2's
- A success (200 OK) or error JSON response is sent to the frontend.
How to Build the Next.js Frontend for OTP Verification
Now, let's create the user interface in Next.js to interact with our backend API for SMS OTP authentication.
3.1. Create the Page Component:
Replace the contents of frontend/app/page.js with the following code:
// frontend/app/page.js
'use client'; // Required for components with hooks like useState, useEffect
import React, { useState } from 'react';
export default function HomePage() {
const [phoneNumber, setPhoneNumber] = useState('');
const [code, setCode] = useState('');
const [requestId, setRequestId] = useState('');
const [verificationStep, setVerificationStep] = useState('request'); // 'request' or 'check'
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState(''); // For success/error messages
const [messageType, setMessageType] = useState(''); // 'success' or 'error'
// --- Configuration ---
// Use environment variables in a real app for the backend URL
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:5001';
// --- Handlers ---
const handleRequestCode = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setMessageType('');
try {
const response = await fetch(`${BACKEND_URL}/api/request-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber }),
});
const data = await response.json();
if (!response.ok) {
// Handle errors from the backend API
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
// Success! Store request ID and move to the next step
// Note: Storing sensitive IDs client-side has security implications. See backend notes.
setRequestId(data.requestId);
setVerificationStep('check');
setMessage('Verification code sent! Please check your phone.');
setMessageType('success');
} catch (error) {
console.error('Request Code Error:', error);
setMessage(error.message || 'Failed to send verification code. Please try again.');
setMessageType('error');
} finally {
setIsLoading(false);
}
};
const handleCheckCode = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setMessageType('');
try {
const response = await fetch(`${BACKEND_URL}/api/check-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, code }), // Send requestId stored in state
});
const data = await response.json();
if (!response.ok) {
// Handle errors from the backend API
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
// Verification successful!
setMessage('Verification successful! Welcome!');
setMessageType('success');
// In a real app, redirect or grant access here
// Reset state for potential future use (optional)
// setVerificationStep('request');
// setPhoneNumber('');
// setCode('');
// setRequestId('');
} catch (error) {
console.error('Check Code Error:', error);
setMessage(error.message || 'Verification failed. Please check the code or request a new one.');
setMessageType('error');
// Optionally allow user to go back or retry
// if (error.message.includes('expired')) {
// setVerificationStep('request'); // Go back if expired
// }
} finally {
setIsLoading(false);
}
};
// --- Render Logic ---
return (
<div style={styles.container}>
<h1>Vonage OTP/2FA Demo</h1>
{message && (
<p style={messageType === 'error' ? styles.errorMessage : styles.successMessage}>
{message}
</p>
)}
{verificationStep === 'request' && (
<form onSubmit={handleRequestCode} style={styles.form}>
<h2>Step 1: Enter Phone Number</h2>
<p>Enter your phone number (e.g., +14155552671) to receive a verification code.</p>
<input
type="tel" // Use standard tel type
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+12345678900"
required
style={styles.input}
disabled={isLoading}
/>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Sending...' : 'Send Code'}
</button>
</form>
)}
{verificationStep === 'check' && (
<form onSubmit={handleCheckCode} style={styles.form}>
<h2>Step 2: Enter Verification Code</h2>
<p>Enter the 6-digit code sent to {phoneNumber}.</p>
<input
type="text" // Use "text" with pattern for better mobile input suggestions
inputMode="numeric" // Hint for numeric keyboard
pattern="\d{6}" // Basic pattern for 6 digits
maxLength="6"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
required
style={styles.input}
disabled={isLoading}
/>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Verifying...' : 'Verify Code'}
</button>
<button
type="button"
onClick={() => {
setVerificationStep('request');
setMessage('');
setCode(''); // Clear code if going back
}}
style={{...styles.button, ...styles.secondaryButton}}
disabled={isLoading}
>
Change Phone Number
</button>
</form>
)}
</div>
);
}
// Basic inline styles for demonstration
const styles = {
container: {
maxWidth: '500px',
margin: '50px auto',
padding: '20px',
fontFamily: 'Arial, sans-serif',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '15px',
},
input: {
padding: '10px',
fontSize: '1rem',
border: '1px solid #ccc',
borderRadius: '4px',
},
button: {
padding: '12px 15px',
fontSize: '1rem',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 0.2s ease',
},
secondaryButton: {
backgroundColor: '#6c757d', // A different color for secondary actions
},
errorMessage: {
color: 'red',
backgroundColor: '#ffebee',
padding: '10px',
borderRadius: '4px',
border: '1px solid red',
},
successMessage: {
color: 'green',
backgroundColor: '#e8f5e9',
padding: '10px',
borderRadius: '4px',
border: '1px solid green',
},
};Explanation:
'use client': Directive required by Next.js App Router for client-side hooks.- State Variables: Manage phone number, code, request ID, UI step, loading state, and feedback messages.
BACKEND_URL: Points to the Express API. UsesNEXT_PUBLIC_BACKEND_URLif set.handleRequestCode: Makes the POST request to/api/request-verification. On success, stores therequestId(with the security caveat mentioned) and moves to the 'check' step.handleCheckCode: Makes the POST request to/api/check-verificationwith the storedrequestIdand the enteredcode. Shows success or error messages.- Render Logic: Conditionally displays the correct form based on
verificationStep. Uses basic inline styles and provides user feedback. Inputtypeattributes are corrected to use standard quotes (e.g.,type="tel").
3.2. Environment Variable for Frontend (Optional but Recommended):
Create a .env.local file in the frontend directory to specify the backend URL during development:
# frontend/.env.local
NEXT_PUBLIC_BACKEND_URL=http://localhost:5001NEXT_PUBLIC_ prefix makes this variable accessible in the browser-side code.
IMPORTANT: After creating or modifying the frontend/.env.local file, you must restart your Next.js development server (npm run dev) for the changes to take effect.
How to Test Your OTP Authentication System
-
Start the Backend Server: Open a terminal, navigate to the
backenddirectory, and run:bashnode server.jsYou should see
Backend server listening at http://localhost:5001. -
Start the Frontend Server: Open another terminal, navigate to the
frontenddirectory, and run:bashnpm run devYou should see output indicating the Next.js server is running, typically at
http://localhost:3000. Remember to restart this if you just created/modified.env.local. -
Test the SMS OTP Flow:
- Open
http://localhost:3000in your browser. - Enter your phone number (including country code, e.g., +1...) and click "Send Code".
- Check your phone for the 6-digit SMS code (or voice call if SMS fails). You should see logs in the backend terminal indicating the request.
- Enter the received code into the second form and click "Verify Code".
- You should see a success message if the code is correct, or an error message otherwise. Check the backend logs for details on success or failure.
- Open
Best Practices for Error Handling and Logging
- Backend Error Handling:
- We use
try...catchwithasync/awaitto handle errors from the Vonage SDK V3+. - We inspect
error.response(specificallyerror.response.statusanderror.response.data) to map Vonage API errors (like 400, 410, 429) to user-friendly messages and appropriate HTTP status codes. - Production Enhancement: Use a dedicated logging library (like
WinstonorPino) instead ofconsole.log/console.errorfor structured logging (JSON format), different log levels (info, warn, error), and outputting logs to files or external services. - Consult the Vonage Verify API V2 Reference for detailed error responses.
- We use
- Frontend Error Handling:
- The frontend checks
response.okand uses the JSON error message from the backend (data.error). - Display clear, non-technical error messages to the user. Avoid exposing raw error details.
- Guide the user on what to do next (e.g., "check the code", "request a new code").
- The frontend checks
- Retry Mechanisms:
- Vonage Internal Retries: Verify V2 workflows can include retries (e.g., SMS -> Voice). Configure workflows as needed via the Vonage API or Dashboard.
- User-Initiated Retries: The frontend allows the user to effectively retry by going back ("Change Phone Number") or implicitly if they enter the wrong code and try again. You could add an explicit "Resend Code" button which would call
/api/request-verificationagain (essential to implement rate limiting). - Backend API Call Retries: For transient network errors when the backend calls Vonage, consider implementing exponential backoff retries (e.g., using libraries like
async-retry), especially if network reliability is a concern.
Database Schema and Secure Session Management
This simple OTP flow doesn't strictly require a database. However, in a real-world application integrating 2FA into a user system, and for more secure handling of the requestId:
- User Table: You'd typically have a
userstable. - 2FA Status: Add columns like
is_2fa_enabled(boolean) andphone_number_for_2fato theuserstable. - Secure
requestIdHandling (Recommended): The approach of sendingrequestIdto the client (used in this guide for simplicity) is less secure because the client controls it. A malicious user could potentially try to check codes against differentrequestIds. The recommended, more secure pattern uses server-side sessions:- User authenticates partially (e.g., logs in with password, or is registering).
- Backend API
/api/request-verificationis called. - Backend stores the received
requestIdin the user's server-side session (e.g., usingexpress-sessionbacked by Redis or a database store). Do NOT send therequestIdback to the client. - Frontend shows the code input form (it doesn't know or need the
requestId). - Frontend POSTs only the
codeto/api/check-verification. - Backend retrieves the
requestIdfrom the user's current session. - Backend calls
vonage.verify.checkusing the sessionrequestIdand the submittedcode. - If successful, mark the session as fully authenticated (2FA passed).
This prevents the client from manipulating the
requestIdduring the check step.
- Migrations: Use a migration tool (like
knex migrationsor Prisma Migrate) if adding 2FA fields to your database schema.
Essential Security Features for Production
-
Input Validation & Sanitization:
- Backend: Use a library like
express-validatorto rigorously validate inputs (phoneNumberformat/length using E.164 checks,codeformat/length,requestIdformat if applicable). Sanitize inputs where necessary. - Frontend: Basic HTML5 validation (
required,type="tel",pattern) provides initial checks but never rely solely on frontend validation.
- Backend: Use a library like
-
Rate Limiting: CRITICAL for OTP endpoints.
- Apply rate limiting to
/api/request-verification(per user ID if logged in, or per IP address) to prevent SMS pumping fraud and abuse. - Apply stricter rate limiting to
/api/check-verification(per user ID/session, or per IP) to prevent brute-force guessing of codes. - Use a library like
express-rate-limit(v8.1.0+ as of 2025).
javascript// Example in backend/server.js (express-rate-limit v8+) const { rateLimit } = require('express-rate-limit'); const requestLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 5, // Limit each IP/user to 5 requests per windowMs message: 'Too many verification requests, please try again after 15 minutes', standardHeaders: 'draft-8', // Use combined RateLimit header legacyHeaders: false, // Disable X-RateLimit-* headers // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available }); const checkLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes limit: 5, // Limit each IP/user to 5 check attempts per windowMs message: 'Too many verification attempts, please try again later.', standardHeaders: 'draft-8', legacyHeaders: false, // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available }); app.post('/api/request-verification', requestLimiter, async (req, res) => { /* ... handler ... */ }); app.post('/api/check-verification', checkLimiter, async (req, res) => { /* ... handler ... */ }); - Apply rate limiting to
-
HTTPS: Always use HTTPS in production to encrypt data in transit. Configure your hosting environment (Vercel, Heroku, Nginx, etc.) to enforce HTTPS.
-
API Key Security: Never commit API keys or secrets directly into your code or version control. Use
.envfiles (added to.gitignoreas shown in setup) and configure environment variables securely in your deployment environment. -
CORS Configuration: In production, configure
corsrestrictively to only allow requests from your specific frontend domain(s).javascript// Example restrictive CORS for production in backend/server.js const allowedOrigins = ['https://your-frontend-domain.com']; // Replace const corsOptions = { origin: function (origin, callback) { if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, optionsSuccessStatus: 200 // For legacy browser support }; app.use(cors(corsOptions)); // Apply restrictive CORS options -
Secure Session Management: If implementing the recommended server-side
requestIdhandling (Section 6), use secure session middleware (express-session) with:- A strong, randomly generated secret.
- Secure cookie settings (
httpOnly: true,secure: true(requires HTTPS),sameSite: 'Lax'or'Strict'). - A suitable session store for production (like Redis or a database store, not the default memory store).
-
Vonage Security Features: Explore Vonage Verify V2 features like fraud detection, workflow customization (e.g., SMS then Voice fallback), and Silent Authentication for enhanced security and user experience.
Frequently Asked Questions About Vonage OTP Authentication
What is Vonage Verify API V2 and how does it differ from V1?
Vonage Verify API V2 provides enhanced two-factor authentication with multiple channels (SMS, Voice, WhatsApp, Email), improved anti-fraud protection, custom webhooks, and a modernized API structure. Unlike V1, it uses a workflow-based approach and supports Silent Authentication for frictionless verification.
What Node.js version should I use for Vonage Verify API V2 in 2025?
Use Node.js v20 (Active LTS) or v22 (Active LTS) for production applications. Node.js v18 reached End-of-Life on March 27, 2025 and should no longer be used. Visit the official Node.js releases page for current LTS status.
How do I get Vonage API credentials?
Sign up for a free Vonage account at the Vonage API Dashboard. After registration, you'll find your API Key and API Secret in the "Getting Started" section of your dashboard. Store these securely in your .env file.
What is the maximum brand name length for Vonage Verify V2?
The brand name can be up to 18 characters long and must match the regex pattern ^[^\/{}:$]*$. This is the name displayed as the sender in OTP messages, helping users identify the source of the verification code.
How many digits should an OTP code be?
The Vonage Verify V2 API supports OTP codes between 4 and 10 digits. The recommended length is 6 digits, which provides a good balance between security (1 million combinations) and user convenience. Configure this with the code_length parameter.
What HTTP status codes does Vonage Verify V2 return?
Common status codes include: 200 (success with {request_id, status: "completed"}), 400 (invalid code or validation error), 409 (concurrent verification not allowed), 410 (request expired or already used), and 429 (rate limit exceeded). Handle these appropriately in your error handling logic.
Why shouldn't I send the requestId to the client?
Sending the requestId to the client allows malicious users to potentially check codes against different request IDs or manipulate the verification process. The secure approach stores the requestId in a server-side session and only sends the verification code to the client.
How do I implement rate limiting for OTP endpoints?
Use the express-rate-limit library (v8.1.0+) to limit requests per IP or user ID. Recommended limits: 5 verification requests per 15 minutes and 5 code check attempts per 5 minutes. This prevents SMS pumping fraud and brute-force attacks.
What is E.164 phone number format?
E.164 is the international standard for phone numbers, starting with a + symbol followed by country code and subscriber number (e.g., +14155552671). Vonage Verify V2 requires phone numbers in E.164 format for proper routing across international carriers.
Can I use Vonage Verify V2 with TypeScript and Tailwind CSS?
Yes, this guide uses plain JavaScript and no CSS framework for simplicity, but you can easily adapt it for TypeScript (add --typescript=yes to the Next.js setup command) and Tailwind CSS (add --tailwind=yes). The Vonage SDK supports TypeScript out of the box.
How do I handle concurrent verification requests?
Vonage Verify V2 prevents concurrent verifications to the same phone number (HTTP 409 error). Inform users they must complete or cancel the current verification before requesting a new one. Implement this in your error handling with a user-friendly message.
What production security measures should I implement?
Essential security measures include: HTTPS enforcement, restrictive CORS configuration, secure session management with Redis or database-backed sessions, input validation with express-validator, comprehensive rate limiting, proper error handling without exposing sensitive details, and storing the requestId server-side only.
Frequently Asked Questions
How to set up Vonage 2FA with Next.js?
Set up separate frontend (Next.js) and backend (Node/Express) projects. Install required dependencies like `express`, `@vonage/server-sdk`, `dotenv`, and `cors` in the backend. Create a `.env` file in the backend to store your Vonage API credentials and brand name securely. Initialize a Next.js project using `create-next-app` for the frontend.
What is Vonage Verify API V2 used for?
Vonage Verify API V2 is a service for sending and verifying one-time passcodes (OTPs) via SMS and voice calls. It simplifies adding two-factor authentication (2FA) to web applications by providing a managed and reliable global service for OTP delivery and verification, reducing the complexity of building 2FA from scratch.
Why does two-factor authentication improve security?
2FA adds a layer of security beyond passwords. By requiring users to possess their phone to receive a time-sensitive code, it significantly reduces unauthorized access even if passwords are compromised. This makes it much harder for attackers to gain access to user accounts, even if they manage to obtain the user's password through phishing or other means.
When should I store the requestId server-side?
Always store the `requestId` server-side in a secure session for production applications. While this guide sends it to the client for demonstration simplicity, this is less secure and could be exploited. Securely storing the `requestId` prevents clients from manipulating it during the check process and helps prevent unauthorized use of the verification code. Use appropriate session management and secure cookie settings in a production environment.
Can I customize the length of the OTP code?
Yes, you can request a 6-digit code by setting `code_length: 6` when calling `vonage.verify.start()`. While the default for Verify API V1 is 4 digits, 6 is more common and provides slightly better security by increasing the number of possible combinations.
How to implement Vonage OTP request in Express?
Create a POST route (e.g., `/api/request-verification`) in your Express backend. Extract the user's phone number from the request body, validate it, and call `vonage.verify.start()` with the number, brand name, and desired code length. Handle errors appropriately, and in a production setting, store the `requestId` in the user's server-side session.
What is the purpose of rate limiting OTP requests?
Rate limiting is crucial to prevent abuse. Limit requests to `/api/request-verification` (e.g., 5 requests per 15 minutes per IP/user) to prevent SMS pumping fraud. Implement stricter limits for `/api/check-verification` (e.g., 5 attempts per 5 minutes) to mitigate brute-force code guessing. Use libraries like `express-rate-limit` for straightforward implementation.
How to verify the Vonage OTP code on the backend?
Create a POST route (e.g., `/api/check-verification`) to receive the OTP code and retrieve the `requestId` from the user's session (secure approach). Call `vonage.verify.check()` with the `requestId` and code. A successful check returns a 200 OK status *without* throwing an error. Handle any errors to provide specific feedback to the user.
How to handle errors from the Vonage Verify API?
The Vonage Node.js SDK V3+ throws errors on API failures. Use `try...catch` blocks to handle them. Inspect `error.response.data` (for API errors) and `error.response.status` for specific error codes (e.g., 400, 410, 429) and provide informative error messages to the user without revealing sensitive details. Use appropriate logging for debugging and monitoring.
What are best practices for securing Vonage API credentials?
Never commit API keys or secrets directly into your codebase. Store them in a `.env` file (which should be added to your `.gitignore`) and load them using the `dotenv` library. Configure environment variables securely in your deployment environment to protect your credentials.
How to improve frontend input validation for phone numbers?
Use HTML input validation such as `type="tel"`, `required` attribute, and input patterns to enforce basic format and improve UX. While client-side validation improves user experience by providing immediate feedback, always perform server-side validation for security.
Why is Cross-Origin Resource Sharing (CORS) important?
CORS is critical when your frontend (Next.js) and backend (Express) run on different ports during development or different domains in production. Configure CORS in your backend using the `cors` middleware, allowing requests from the appropriate origin(s). In production, restrict CORS to your frontend's domain to enhance security.
What is the recommended way to handle the requestId securely?
The most secure approach is to store the `requestId` in a server-side session associated with the user and never send it to the client. When checking the code, retrieve the `requestId` from the session, preventing client-side manipulation.
What database schema is recommended for user authentication with 2FA?
A `users` table should contain columns for standard user information (like username, password hash). To incorporate 2FA, include columns such as `is_2fa_enabled` (boolean) and `phone_number_for_2fa`. Use database migrations to manage schema changes effectively.
How to resend verification code in a secure way?
Implement a 'Resend Code' button on the frontend. Upon clicking, make a request to your backend's `/api/request-verification` endpoint with the phone number (same logic as initial request), implementing rate limiting to prevent abuse. Update the user's session with the new requestId if storing it server-side. In the case of client-side requestId storage, send the new requestId to the client. Always enforce rate limits to prevent abuse.