How to Implement Infobip SMS OTP & 2FA in Next.js: Complete Node.js Guide
Step-by-step tutorial for implementing SMS OTP verification with Infobip API in Next.js. Includes code examples, security best practices, and production deployment patterns.
Learn how to build secure SMS-based two-factor authentication (2FA) in Next.js applications using Infobip's API. This comprehensive tutorial covers implementing OTP verification for user registration, login security, and transaction confirmation with complete code examples and production-ready patterns.
⚠️ Critical Security Notice:
Before implementing SMS-based 2FA, understand these security limitations:
NIST Restriction: NIST SP 800-63B designates SMS/PSTN authentication as "RESTRICTED" due to known vulnerabilities including SIM swapping and SS7 protocol attacks
OWASP Guidance: SMS OTP "should not be used to protect applications that hold Personally Identifiable Information (PII) or where there is financial risk"
Regulatory Requirement: If using SMS 2FA, you must offer at least one non-RESTRICTED alternative (authenticator apps, hardware tokens, or passkeys)
Use Case Suitability: SMS OTP is acceptable for low-risk scenarios like basic account verification but unsuitable for financial transactions, healthcare data access, or sensitive PII protection
Recommended Alternatives: For higher security requirements, consider implementing TOTP (Time-based One-Time Password) apps, WebAuthn/FIDO2, or hardware security keys alongside or instead of SMS.
What You'll Build: A complete SMS OTP authentication system that sends verification codes via Infobip's messaging service and validates user input through secure API routes.
Real-World Use Cases:
Financial Services: Banks use SMS OTP as part of multi-layered authentication for transaction confirmations and secure login flows. However, per NIST guidelines, SMS must be combined with non-RESTRICTED authenticators (hardware tokens, biometrics) for PCI-DSS compliance and financial transaction protection.
E-commerce Platforms: Online retailers implement phone verification during checkout to prevent fraud and chargebacks – an appropriate low-to-medium risk use case for SMS OTP.
Healthcare Applications: Medical platforms verifying patient identity before allowing access to sensitive health records must implement SMS OTP alongside stronger authentication methods to meet HIPAA security requirements, as SMS alone is insufficient for PHI (Protected Health Information) access.
Enterprise Systems: Companies secure employee access to internal systems and validate identity for password resets – suitable for SMS OTP when combined with other security controls for high-privilege accounts.
Business Benefits:
Fraud Reduction: Studies show 2FA reduces account takeover attacks by up to 99.9%
Regulatory Compliance: Meets requirements for PCI-DSS, HIPAA, and other security frameworks
Customer Trust: Users report higher confidence in platforms that implement additional security layers
Master the complete implementation process including Infobip API configuration, secure Next.js API route development, React component creation, and production deployment best practices. Perfect for developers building authentication systems, e-commerce checkout flows, or any application requiring phone number verification.
For more Next.js authentication patterns, check out our guide on implementing JWT authentication in Next.js and building secure user registration systems.
Building SMS Authentication: Project Overview
What We're Building:
We will implement a feature where a user can enter their phone number, receive an OTP via SMS powered by Infobip, and then verify that OTP within the Next.js application. This forms the core of a 2FA or phone verification system.
Problem Solved:
Compliance and Regulatory Requirements:
GDPR Compliance: Phone numbers constitute personal data requiring valid legal basis (consent or legitimate interest). For transactional OTP messages, these are considered necessary service communications and don't require advertising consent. However, you must implement proper data retention policies and honor data subject rights including data portability and erasure requests.
PCI-DSS Requirements: Payment processing systems often mandate multi-factor authentication for sensitive operations. SMS OTP satisfies PCI-DSS requirements for "something you have" authentication factors.
Data Retention: Under GDPR, organizations must define retention periods for personal data and automate deletion when retention periods expire or upon data subject request.
Enhanced Security: Adds a second layer of authentication beyond passwords, with ENISA recommending 2FA implementation for high-risk cases involving personal data processing.
User Verification: Confirms user identity by validating phone number ownership.
Fraud Prevention: Helps prevent fake account creation and secures transactions. Financial institutions report up to 99.9% reduction in account takeover attacks when implementing proper 2FA systems.
Technologies Involved:
Next.js: React framework for building the frontend UI and backend API routes.
Node.js: Runtime environment for Next.js API routes.
Infobip: Cloud communications platform providing the 2FA/OTP service via API.
Infobip Node.js SDK: Simplifies interaction with the Infobip API.
React: For building the frontend components within Next.js.
System Architecture:
The flow involves three main components:
Frontend (Next.js/React): Captures the user's phone number and the OTP they receive. Makes requests to the Next.js backend.
Backend (Next.js API Routes): Acts as a secure intermediary. It receives requests from the frontend, interacts with the Infobip API using credentials stored securely, and sends responses back to the frontend.
Infobip API: Handles the generation, sending (via SMS), and verification of the OTP.
Node.js 22 LTS (recommended for 2025) or minimum Node.js 18.17.0 (required for Next.js compatibility). Node.js 22 is the current Active LTS version, providing optimal security and performance until April 2027.
npm/yarn: Latest stable version for package management
An active Infobip account (Sign up here if you don't have one)
Basic understanding of Next.js (Pages Router or App Router), React, and API concepts
A text editor (VS Code recommended with extensions for Next.js development)
For additional Next.js setup guidance, see our Next.js development environment setup guide and React authentication patterns tutorial.
Security Prerequisites:
HTTPS Environment: Production deployment must use HTTPS to encrypt data in transit. Platforms like Vercel handle this automatically.
Secure Environment Management: Understanding of environment variable security and .gitignore configuration
Basic Security Awareness: Familiarity with API key protection, rate limiting concepts, and secure coding practices
1. Next.js Project Setup for SMS Authentication
Follow these step-by-step instructions to set up your Next.js project with all required dependencies for SMS authentication.
1.1 Create Next.js Project:
Open your terminal and run:
Bash
npx create-next-app@latest infobip-otp-nextjs
# Follow the prompts (e.g., choose TypeScript if desired, App Router or Pages Router)cd infobip-otp-nextjs
1.2 Install Infobip Node.js SDK:
This SDK provides convenient methods for interacting with the Infobip API.
Bash
npm install infobip-api-nodejs-sdk
# or
yarn add infobip-api-nodejs-sdk
1.3 Environment Variables:
We need to store sensitive Infobip credentials securely. Create a file named .env.local in the root of your project. Never commit this file to version control.
Essential .gitignore Setup:
First, ensure your .gitignore file includes all environment files:
For team collaboration, create .env.example with the structure but no sensitive values:
.env
# .env.example - Template for team setup
INFOBIP_BASE_URL=your_infobip_base_url_here
INFOBIP_API_KEY=your_infobip_api_key_here
INFOBIP_2FA_APP_ID=your_2fa_application_id_here
INFOBIP_2FA_MESSAGE_ID=your_2fa_message_template_id_here
.env
# .env.local
# --- IMPORTANT ---
# The values below are placeholders. You MUST replace them with your actual
# credentials and configuration IDs obtained from your Infobip account.
# Failure to do so will prevent the integration from working.
# --- IMPORTANT ---
# Obtain from Infobip Portal (Homepage -> API Base URL for your region)
INFOBIP_BASE_URL=your_infobip_base_url
# Obtain from Infobip Portal (API Key Management)
INFOBIP_API_KEY=your_infobip_api_key
# These will be obtained after configuring the 2FA Application and Message Template in Infobip
INFOBIP_2FA_APP_ID=your_2fa_application_id
INFOBIP_2FA_MESSAGE_ID=your_2fa_message_template_id
Why .env.local? Next.js automatically loads variables from this file into process.env on the server side (API routes), keeping your secrets out of the frontend bundle and source control.
1.4 Project Structure:
Your basic structure (using Pages Router for simplicity in examples, adapt if using App Router):
Text
infobip-otp-nextjs/
├── lib/
│ └── infobip.js # Utility for Infobip client initialization
├── pages/
│ ├── api/
│ │ ├── send-otp.js # API route to send OTP
│ │ └── verify-otp.js # API route to verify OTP
│ ├── _app.js
│ └── index.js # Frontend page for OTP interaction
├── public/
├── styles/
├── .env.local # Your secret credentials
├── package.json
└── node_modules/
2. Infobip SMS API Configuration Guide
Before writing code, we need to configure the necessary components within the Infobip platform.
Base URL: On the homepage, locate your API Base URL. It's region-specific (e.g., xxxxx.api.infobip.com).
Region Selection Guidance:
Choose the region closest to your primary user base for optimal latency
US East (Virginia) for North American users
EU (Ireland) for European users
Asia Pacific (Singapore) for Asian users
Consider regulatory requirements (EU data residency for GDPR compliance)
Copy this value into INFOBIP_BASE_URL in your .env.local.
API Key:
Navigate to API Key Management. Note: The exact name and location of this section within the Infobip Portal UI might change over time. Look for settings related to API access or developer tools.
Click Create API Key.
Give it a descriptive name (e.g., Nextjs OTP App - Production).
Assign Roles/Permissions:
Minimum Required Permission: Grant only the "TFA" scope for 2FA SMS PIN functionality. This is the specific permission required for send and verify PIN operations in 2025.
Avoid Broad Permissions: Do not grant full access or additional scopes unless absolutely required for your use case.
Security Enhancements:
Add IP restrictions for production environments
Set expiration dates for API keys where possible
Use separate API keys for development, staging, and production environments
Click Submit. Copy the generated API key immediately, as it won't be shown again. Paste it into INFOBIP_API_KEY in your .env.local.
API Key Rotation Best Practices:
Rotate API keys every 90-180 days or after team member changes
Implement key rotation without service interruption using overlapping key validity periods
Monitor API key usage through Infobip's analytics dashboard
Immediately revoke compromised keys and generate new ones
2.2 Create a 2FA Application:
This defines the rules for your OTP flow (e.g., how many attempts are allowed, how long the OTP is valid).
In the Infobip Portal, navigate to the Apps section (or search for 2FA Applications).
Click Create Application.
Configure the settings with detailed explanations:
Application Name: Give it a clear name (e.g., My Next.js App Verification).
Configuration Settings Explained:
pinAttempts: Max number of verification attempts (recommended: 3-5). Impact: Higher values increase user convenience but reduce security. Lower values may cause user frustration with legitimate typos.
allowMultiplePinVerifications: false (recommended). Impact: Set to false for security - each PIN can only be verified once. Setting to true allows reuse of the same PIN multiple times within the TTL window.
pinTimeToLive: How long the OTP is valid (recommended: 5m for 5 minutes). Impact: Shorter TTL increases security but may frustrate users. Longer TTL improves UX but extends vulnerability window.
verifyPinLimit: Rate limit for verification attempts (recommended: 1/3s - 1 attempt per 3 seconds). Impact: Prevents brute force attacks while allowing reasonable retry intervals.
sendPinPerApplicationLimit: Global rate limit (e.g., 10000/1d - 10,000 per day). Cost Impact: This directly affects your SMS costs. At ~$0.0079 per US SMS, 10,000 daily messages ≈ $79/day or $2,400/month.
sendPinPerPhoneNumberLimit: Per-number limit (recommended: 3-5/1d - 3-5 per day per number). Impact: Prevents abuse while allowing legitimate retries. Higher limits increase spam potential and costs.
Enabled: Ensure it's set to true.
Click Submit.
Copy the generated Application ID (applicationId). Paste this into INFOBIP_2FA_APP_ID in your .env.local.
2.3 Create a 2FA Message Template:
This defines the content of the SMS message sent to the user, including the OTP placeholder.
Within the 2FA Application you just created, navigate to the Message Templates section.
Click Create Message Template.
Configure the settings:
PIN Type:NUMERIC (most common).
Message Text: The SMS content. Crucially, include the {{pin}} placeholder where the OTP should be inserted. Example: Your verification code for My App is {{pin}}. It expires in 5 minutes.
PIN Length: Number of digits for the OTP (e.g., 6). Remember this value for validation.
Sender ID: The name/number displayed as the SMS sender (subject to registration/regulations depending on the country). You might use a default like InfoSMS or configure a custom one if available on your account.
Language: Select the language (e.g., en for English).
Click Submit.
Copy the generated Message ID (messageId). Paste this into INFOBIP_2FA_MESSAGE_ID in your .env.local.
Why separate Application and Template? This allows you to reuse the same application rules (limits, TTL) with different message templates (e.g., for different languages or slightly different wording for registration vs. login).
3. Building SMS OTP API Endpoints in Next.js
Now, let's create the backend endpoints that our frontend will call.
3.1 Initialize Infobip Client:
It's good practice to create a utility function or singleton instance for the Infobip client.
Create lib/infobip.js (or .ts):
JavaScript
// lib/infobip.jsimport { Infobip } from""infobip-api-nodejs-sdk"";
let infobipInstance = null;
// Environment variable validationconstvalidateEnvironment = () => {
const required = {
INFOBIP_BASE_URL: process.env.INFOBIP_BASE_URL,
INFOBIP_API_KEY: process.env.INFOBIP_API_KEY,
INFOBIP_2FA_APP_ID: process.env.INFOBIP_2FA_APP_ID,
INFOBIP_2FA_MESSAGE_ID: process.env.INFOBIP_2FA_MESSAGE_ID
};
const missing = Object.entries(required)
.filter(([key, value]) => !value)
.map(([key]) => key);
if (missing.length > 0) {
thrownewError(`Missing required environment variables: ${missing.join(', ')}`);
}
return required;
};
exportconstgetInfobipClient = () => {
if (!infobipInstance) {
validateEnvironment();
infobipInstance = newInfobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
// Timeout configuration for production reliabilitytimeout: 30000, // 30 seconds timeout// Optional: Add retry configuration// retryConfig: {// maxRetries: 2,// retryDelay: 1000 // 1 second between retries// }
});
}
return infobipInstance;
};
// TypeScript version (create lib/infobip.ts instead):/*
import { Infobip } from 'infobip-api-nodejs-sdk';
interface InfobipConfig {
baseUrl: string;
apiKey: string;
timeout?: number;
}
interface RequiredEnvVars {
INFOBIP_BASE_URL: string;
INFOBIP_API_KEY: string;
INFOBIP_2FA_APP_ID: string;
INFOBIP_2FA_MESSAGE_ID: string;
}
let infobipInstance: Infobip | null = null;
const validateEnvironment = (): RequiredEnvVars => {
const config = {
INFOBIP_BASE_URL: process.env.INFOBIP_BASE_URL,
INFOBIP_API_KEY: process.env.INFOBIP_API_KEY,
INFOBIP_2FA_APP_ID: process.env.INFOBIP_2FA_APP_ID,
INFOBIP_2FA_MESSAGE_ID: process.env.INFOBIP_2FA_MESSAGE_ID
};
const missing = Object.entries(config)
.filter(([key, value]) => !value)
.map(([key]) => key);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
return config as RequiredEnvVars;
};
export const getInfobipClient = (): Infobip => {
if (!infobipInstance) {
validateEnvironment();
const config: InfobipConfig = {
baseUrl: process.env.INFOBIP_BASE_URL!,
apiKey: process.env.INFOBIP_API_KEY!,
timeout: 30000
};
infobipInstance = new Infobip(config);
}
return infobipInstance;
};
*/// Note on Singletons and Serverless Environments:// This singleton pattern works well for typical Next.js server deployments.// However, in serverless environments (like Vercel Serverless Functions),// the lifecycle might lead to multiple instances or unexpected state retention// across invocations if not managed carefully. For simple use cases like this,// it's often acceptable, but be mindful of potential scaling or state issues// in complex serverless scenarios. Consider initializing the client per-request// if necessary, though this might have a slight performance overhead.
3.2 API Route: Send OTP (/api/send-otp)
This route receives a phone number, uses the Infobip SDK to send an OTP, and returns the pinId needed for verification.
JavaScript
// pages/api/send-otp.jsimport { getInfobipClient } from""../../lib/infobip"";
// Consider using a robust phone number validation library for production// import { parsePhoneNumberFromString } from 'libphonenumber-js';// Placeholder for a structured logger (replace console calls in production)// import logger from '../../lib/logger'; // Example: Your logger setupexportdefaultasyncfunctionhandler(req, res) {
if (req.method !== ""POST"") {
res.setHeader(""Allow"", [""POST""]);
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
}
const { phoneNumber } = req.body;
// --- Phone Number Validation ---// Basic Regex (Insecure for Production): Allows 10-15 digits. Doesn't validate country codes.const basicPhoneNumberRegex = /^\d{10,15}$/;
if (!phoneNumber || !basicPhoneNumberRegex.test(phoneNumber)) {
// Production Recommendation: Use a library like libphonenumber-js// const parsedNumber = parsePhoneNumberFromString(phoneNumber); // Requires country code usually// if (!parsedNumber || !parsedNumber.isValid()) {// return res.status(400).json({ message: ""Valid E.164 phone number format required (e.g., +14155552671)."" });// }// Using basic validation for this example:return res.status(400).json({ message: ""Valid phone number is required (10-15 digits, include country code)."" });
}
// --- End Phone Number Validation ---if (!process.env.INFOBIP_2FA_APP_ID || !process.env.INFOBIP_2FA_MESSAGE_ID) {
// logger.error(""Server configuration error: Infobip App ID or Message ID missing.""); // Use loggerconsole.error(""Server configuration error: InfobipAppID or MessageID missing."");
return res.status(500).json({ message: ""Server configuration error."" });
}
try {
const infobip = getInfobipClient();
// logger.info(`Attempting to send OTP to ${phoneNumber}`); // Use loggerconst response = await infobip.channels.sms.sendTfaPin({
applicationId: process.env.INFOBIP_2FA_APP_ID,
messageId: process.env.INFOBIP_2FA_MESSAGE_ID,
// 'from' can often be omitted if defined in the template or account defaults// from: ""YourSenderID"",to: phoneNumber, // Assumes phoneNumber includes country code from input// Optional: Define placeholder values if your template uses them beyond {{pin}}// placeholders: { ""firstName"": ""User"" }
});
// Successfully initiated OTP send// logger.info({ msg: ""Infobip Send OTP Response OK"", pinId: response.data.pinId }); // Use loggerconsole.log(""InfobipSendOTPResponse:"", response.data); // Basic logging// IMPORTANT: Return the pinId to the client for the verification stepreturn res.status(200).json({ pinId: response.data.pinId });
} catch (error) {
const errorData = error.response ? error.response.data : error.message;
// logger.error({ msg: ""Error sending OTP via Infobip"", error: errorData }); // Use loggerconsole.error(""Error sending OTP via Infobip:"", errorData); // Basic logging// Provide a generic error to the client, log specifics server-sidelet statusCode = 500;
let message = ""Failed to send OTP."";
// Handle specific Infobip error codes if neededif (error.response && error.response.data && error.response.data.requestError) {
const serviceException = error.response.data.requestError.serviceException;
if (serviceException) {
message = serviceException.text || message;
// You could map specific messageIds (like rate limits) to 429 statusif (serviceException.messageId === 'TOO_MANY_REQUESTS') {
statusCode = 429;
}
// Add other specific error mappings if desired
}
} elseif (error.response && error.response.status) {
statusCode = error.response.status; // Use status from Infobip if available
}
return res.status(statusCode).json({ message });
}
}
3.3 API Route: Verify OTP (/api/verify-otp)
This route receives the pinId (obtained from the send step) and the pin entered by the user. It calls Infobip to verify.
JavaScript
// pages/api/verify-otp.jsimport { getInfobipClient } from""../../lib/infobip"";
// Placeholder for a structured logger (replace console calls in production)// import logger from '../../lib/logger'; // Example: Your logger setupexportdefaultasyncfunctionhandler(req, res) {
if (req.method !== ""POST"") {
res.setHeader(""Allow"", [""POST""]);
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
}
const { pinId, pin } = req.body;
// --- Input Validation ---if (!pinId) {
return res.status(400).json({ message: ""PinID is required."" });
}
// PIN Validation:// The regex below assumes a PIN length between 4 and 10 digits.// Ideally, this should match the 'PIN Length' configured in your// Infobip 2FA Message Template (Section 2.3).// Consider making this dynamic or ensuring it matches your config.const pinRegex = /^\d{4,10}$/; // Example: Adjust '4' and '10' to match your template's PIN lengthif (!pin || !pinRegex.test(pin)) {
return res.status(400).json({ message: ""ValidPIN format required."" });
}
// --- End Input Validation ---try {
const infobip = getInfobipClient();
// logger.info(`Attempting to verify OTP for pinId: ${pinId}`); // Use loggerconst response = await infobip.channels.sms.verifyTfaPin(pinId, {
pin: pin
});
// logger.info({ msg: ""Infobip Verify OTP Response OK"", pinId: pinId, verified: response.data.verified }); // Use loggerconsole.log(""InfobipVerifyOTPResponse:"", response.data); // Basic loggingif (response.data.verified) {
// OTP Verified Successfully!// Perform actions here: e.g., log user in, complete registration, update database flag// logger.info(`OTP verification successful for pinId: ${pinId}`); // Use loggerreturn res.status(200).json({ verified: true, message: ""OTP verified successfully."" });
} else {
// OTP Incorrect or Expired (or other non-verified state)// Note: Infobip might return a 200 OK with verified: false for incorrect PINs// within the attempt limit. Errors (4xx/5xx) usually indicate other issues.// logger.warn(`OTP verification failed for pinId: ${pinId} - Not verified by Infobip.`); // Use loggerreturn res.status(400).json({ verified: false, message: ""Invalid or expired OTP."" });
}
} catch (error) {
const errorData = error.response ? error.response.data : error.message;
// logger.error({ msg: ""Error verifying OTP via Infobip"", pinId: pinId, error: errorData }); // Use loggerconsole.error(""Error verifying OTP via Infobip:"", errorData); // Basic logginglet statusCode = 500;
let message = ""Failed to verify OTP."";
let verified = false;
// Check for specific verification errors (e.g., wrong PIN, expired, max attempts)if (error.response && error.response.data && error.response.data.requestError) {
const serviceException = error.response.data.requestError.serviceException;
if (serviceException) {
message = serviceException.text || message; // Use Infobip's error text// Map specific errors if needed, e.g., differentiate between wrong PIN and expired PINif (serviceException.messageId === 'PIN_NOT_FOUND') {
message = 'OTP session not found or expired.';
statusCode = 404; // Not Found is appropriate here
} elseif (serviceException.messageId === 'WRONG_PIN') {
message = 'Invalid OTP provided.';
statusCode = 400; // Bad Request (client error)
} elseif (serviceException.messageId === 'MAX_ATTEMPTS_EXCEEDED') {
message = 'Maximum verification attempts reached.';
statusCode = 429; // Too Many Requests
} else {
// Use Infobip's status code if available and seems appropriate
statusCode = error.response.status >= 400 ? error.response.status : 400;
}
}
} elseif (error.response && error.response.status) {
statusCode = error.response.status;
}
return res.status(statusCode).json({ verified, message });
}
}
API Testing (Example using curl):
Replace placeholders with actual values. Remember to start your Next.js development server (npm run dev or yarn dev).
Send OTP:
Bash
curl -X POST http://localhost:3000/api/send-otp \
-H ""Content-Type: application/json"" \
-d '{""phoneNumber"": ""+14155552671""}'# Use a real number you can receive SMS on, including country code
Verify OTP (using pinId from above and the received SMS code):
Bash
curl -X POST http://localhost:3000/api/verify-otp \
-H ""Content-Type: application/json"" \
-d '{""pinId"": ""SOME_PIN_ID_FROM_INFOBIP"", ""pin"": ""123456""}'# Replace 123456 with the actual OTP
Expected Response (Success):{""verified"": true, ""message"": ""OTP verified successfully.""}Expected Response (Invalid PIN):{""verified"": false, ""message"": ""Invalid or expired OTP.""} (or more specific error)
4. Creating the OTP Verification UI Components
Now, let's create a simple UI for users to interact with our OTP flow.
JSX
// pages/index.jsimportReact, { useState } from'react';
// Frontend Styling Note:// This example uses basic inline styles (`style={{ ... }}`) for simplicity.// For production applications, it's highly recommended to use more maintainable// styling solutions like CSS Modules, Tailwind CSS, styled-components, or Emotion,// which integrate well with Next.js.exportdefaultfunctionOtpPage() {
const [phoneNumber, setPhoneNumber] = useState('');
const [pinId, setPinId] = useState(''); // State to hold the pinId from the backendconst [otp, setOtp] = useState('');
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isOtpSent, setIsOtpSent] = useState(false); // Controls which form part is shownconst [isVerified, setIsVerified] = useState(false); // Controls final success messageconsthandleSendOtp = async (e) => {
e.preventDefault();
setMessage('');
setIsLoading(true);
setIsVerified(false);
setIsOtpSent(false); // Reset OTP statesetPinId(''); // Reset pinId// Frontend Input Consideration:// While backend validation is essential, consider adding basic frontend// format masking (e.g., using react-imask) or validation for the phone number// input to improve user experience and reduce invalid submissions.try {
const res = awaitfetch('/api/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber }),
});
const data = await res.json();
if (res.ok) {
setPinId(data.pinId); // Store the pinId received from the APIsetIsOtpSent(true);
setMessage('OTP sent successfully! Please check your phone.');
} else {
setMessage(`Error: ${data.message || 'Failed to send OTP'}`);
setIsOtpSent(false);
}
} catch (error) {
console.error(""FrontendErrorSendingOTP:"", error);
setMessage('An unexpected error occurred. Please try again.');
setIsOtpSent(false);
} finally {
setIsLoading(false);
}
};
consthandleVerifyOtp = async (e) => {
e.preventDefault();
setMessage('');
setIsLoading(true);
try {
const res = awaitfetch('/api/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinId, pin: otp }), // Send pinId and the entered OTP
});
const data = await res.json();
if (res.ok && data.verified) {
setIsVerified(true);
setMessage('Success! Phone number verified.');
// Optionally reset the form or navigate the usersetPinId('');
setOtp('');
setIsOtpSent(false); // Go back to phone number input stage for next time// setPhoneNumber(''); // Optional: clear phone number too
} else {
// Handle verification failure (invalid OTP, expired, etc.)setMessage(`Error: ${data.message || 'Failed to verify OTP'}`);
setIsVerified(false);
setOtp(''); // Clear OTP input field for retry attempt
}
} catch (error) {
console.error(""FrontendErrorVerifyingOTP:"", error);
setMessage('An unexpected error occurred during verification.');
setIsVerified(false);
} finally {
setIsLoading(false);
}
};
// Handler for the ""Change/Resend"" buttonconsthandleResetFlow = () => {
setIsOtpSent(false); // Go back to the phone number input stagesetMessage('');
setOtp('');
setPinId('');
setIsVerified(false);
// The phone number input remains populated, allowing easy resend or correction.
};
return (
<divstyle={{maxWidth: '400px', margin: '50pxauto', padding: '20px', border: '1pxsolid #ccc', borderRadius: '8px' }}><h1>Phone Verification</h1>
{/* Show forms only if not successfully verified */}
{!isVerified && (
<formonSubmit={!isOtpSent ? handleSendOtp:handleVerifyOtp}>
{!isOtpSent ? (
// Stage 1: Enter Phone Number
<><labelhtmlFor=""phoneNumber"" style={{display: 'block', marginBottom: '5px' }}>Phone Number:</label><inputtype=""tel"" // Use ""tel"" typeforbettermobileUXid=""phoneNumber""
value={phoneNumber}onChange={(e) => setPhoneNumber(e.target.value)}
placeholder=""e.g., +14155552671 (with country code)""
required
disabled={isLoading}
style={{ width: '100%', padding: '8px', marginBottom: '10px', boxSizing: 'border-box' }}
/>
<buttontype=""submit"" disabled={isLoading}style={{width: '100%', padding: '10px' }}>
{isLoading ? 'Sending...' : 'Send OTP'}
</button></>
) : (
// Stage 2: Enter OTP
<><p>Enter the OTP sent to {phoneNumber}.</p><labelhtmlFor=""otp"" style={{display: 'block', marginBottom: '5px' }}>OTP:</label><inputtype=""text"" // Use ""text"" andpattern/maxLengthforbettercontrolinputMode=""numeric"" // Hintfornumerickeyboardonmobileid=""otp""
value={otp}onChange={(e) => setOtp(e.target.value)}
maxLength={6} // Match your PIN length configured in Infobip
pattern=""\d*"" // Allow only digits
required
disabled={isLoading}
autoComplete=""one-time-code"" // Helps browsers/password managers autofill OTP
style={{ width: '100%', padding: '8px', marginBottom: '10px', boxSizing: 'border-box' }}
/>
<buttontype=""submit"" disabled={isLoading}style={{width: '100%', padding: '10px', marginBottom: '10px' }}>
{isLoading ? 'Verifying...' : 'Verify OTP'}
</button>
{/* Button to go back to phone input / allow resend */}
<buttontype=""button""
onClick={handleResetFlow}disabled={isLoading}style={{width: '100%', padding: '10px', background: '#eee', border: '1pxsolid #ccc', cursor: 'pointer' }}
>
Change Phone Number / Resend OTP
</button></>
)}
</form>
)}
{/* Display messages (errors or success) */}
{message && (
<pstyle={{marginTop: '15px', color:isVerified ? 'green' : (message.startsWith('Error:') ? 'red' : 'black') }}>
{message}
</p>
)}
</div>
);
}
5. Production Error Handling and Monitoring
API Routes: We've included try...catch blocks.
Production Logging: Replace console.log and console.error with a dedicated, structured logging library (e.g., Pino, Winston) or integrate with a logging service (e.g., Sentry, Logtail, Datadog). This allows for better filtering, searching, and alerting in production. Log detailed error information server-side, including Infobip's response data when available (error.response.data).
User Messages: Return user-friendly, non-revealing error messages to the client (e.g., ""Invalid OTP"" instead of exposing internal error codes).
Infobip Errors: Handle specific Infobip errors (rate limits 429, invalid credentials 401, etc.) by checking error.response.status and error.response.data.requestError.serviceException.messageId.
Frontend: Use try...catch around fetch calls. Display clear messages to the user based on API responses. Provide ways to retry (like the ""Resend OTP"" button) or restart the process.
Retry Mechanisms: For transient network issues calling Infobip from your backend, you could implement a simple retry in the API route (e.g., retry once after a short delay using libraries like async-retry). Be cautious with retrying OTP sends to avoid spamming users or hitting rate limits quickly. Exponential backoff is generally recommended for retrying external API calls.
6. SMS Authentication Security Best Practices
API Key Security: Absolutely crucial. Use environment variables (.env.local for local development) and never expose your API key in frontend code or commit it to version control. Configure restricted permissions (least privilege principle) and consider IP allowlisting for your Infobip API key in production.
Rate Limiting:
Infobip: Configure sensible limits (verifyPinLimit, sendPinPerPhoneNumberLimit, sendPinPerApplicationLimit) in your Infobip 2FA Application settings to prevent abuse at the source.
Your API Routes: Implement rate limiting on your /api/send-otp and /api/verify-otp endpoints to protect your own server resources. Libraries like rate-limiter-flexible or express-rate-limit (adapted for Next.js API routes) can be used. Hosting platforms like Vercel also offer built-in rate limiting features.
Input Validation: Sanitize and rigorously validate all inputs (phone number format, PIN format, pinId presence) on the server-side (API routes). Do not rely solely on frontend validation, as it can be bypassed. Use robust libraries like libphonenumber-js for phone numbers.
PIN ID Handling (Security Trade-off):
Current Implementation: This guide sends the pinId to the client and back. This is simpler to implement but less secure, as the pinId is exposed. An attacker who obtains a pinId could potentially attempt to guess the PIN by directly calling your /api/verify-otp endpoint (though Infobip's rate limits offer protection).
Recommended Production Pattern (More Secure): Do not send the pinId to the client. Instead, store the pinId server-side immediately after the /api/send-otp call. Associate it with the user's session (e.g., using next-auth or encrypted cookies) or a temporary secure cache (like Redis) keyed by session ID or phone number, with a TTL matching Infobip's pinTimeToLive. When the user submits the OTP to /api/verify-otp, retrieve the correct pinId from the server-side storage based on their session/context and then call Infobip. This prevents pinId exposure but adds server-side state management complexity.
Brute Force Protection: Infobip's pinAttempts setting provides primary protection against guessing the OTP. Your API route rate limiting adds another layer of defense against hammering the verification endpoint.
HTTPS: Always use HTTPS for your application to encrypt data in transit between the client, your server, and Infobip. Platforms like Vercel handle this automatically.
CSRF Protection: Next.js API routes generally require same-origin requests, offering some CSRF protection. Ensure you aren't disabling security features and understand CSRF risks if using complex authentication flows or custom headers.
7. Testing SMS OTP Implementation
Infobip Test Numbers/Sandbox: Check the current Infobip documentation regarding sandbox environments or dedicated test numbers. While sometimes available, they might have limitations. Often, developers need to use real phone numbers for testing the end-to-end SMS delivery flow, which may incur small costs. Ensure you understand any associated costs or limitations.
Unit Tests: Use testing frameworks like Jest or Vitest to write unit tests for your API routes (send-otp.js, verify-otp.js). Mock the infobip-api-nodejs-sdk using jest.mock or similar techniques to simulate:
Successful OTP send responses (returning a mock pinId).
Specific Infobip error responses (invalid PIN, expired, rate limit, invalid API key).
Network errors during the Infobip API call.
Test your input validation logic thoroughly (valid/invalid phone numbers, PINs, missing pinId).
Integration Tests: Test the flow between your frontend and backend API routes. Use tools like Cypress or Playwright to simulate user interaction: entering a phone number, clicking ""Send OTP"", receiving a (mocked or real) OTP, entering it, and clicking ""Verify OTP"". Mock the fetch calls in your frontend tests or run against a live development server (potentially mocking the Infobip SDK at the API level).
End-to-End (E2E) Tests: For critical flows, consider E2E tests that interact with a staging environment connected to Infobip (using test credentials and numbers if possible).
This validates the entire system, including the actual Infobip integration, but can be more complex and costly to set up and run.
8. Deploying Your Next.js OTP System to Production
When deploying your SMS OTP authentication system, consider these platform-specific configurations:
Vercel Deployment (Recommended for Next.js):
Environment variables: Add all INFOBIP_* variables in Vercel dashboard under Project Settings → Environment Variables
Serverless function limits: Default 10-second timeout (sufficient for Infobip API calls)
Regional deployment: Choose regions close to your Infobip API region for optimal latency
Environment Variable Management:
Bash
# Production deployment checklist
vercel env add INFOBIP_BASE_URL production
vercel env add INFOBIP_API_KEY production
vercel env add INFOBIP_2FA_APP_ID production
vercel env add INFOBIP_2FA_MESSAGE_ID production
Performance Optimization:
Enable Next.js caching for the Infobip client singleton
Implement connection pooling for database operations (if storing verification logs)
Monitor API response times via Vercel Analytics or custom logging
Set appropriate timeout values (30 seconds recommended for SMS API calls)
Monitoring and Alerting:
Track OTP send success rates
Monitor verification attempt patterns for abuse detection
Set up alerts for API rate limit approaches
Log all security-related events (failed verifications, rate limit hits)
Integrate Infobip's 2FA by setting up API routes in your Next.js application to handle the backend logic for sending and verifying OTPs. Use the Infobip Node.js SDK to interact with the Infobip API and manage the OTP workflow securely. The frontend will communicate with these API routes using fetch requests.
Next.js API routes act as a secure intermediary between the frontend and the Infobip API. They handle requests from the frontend, interact with the Infobip API using your secure credentials, and send responses back to the frontend, ensuring that sensitive information is not exposed directly to the client-side.
Infobip enhances security by adding 2FA, verifying user identity through phone number validation, and helping prevent fraudulent account creation. It utilizes OTPs sent via SMS, adding an extra layer of security beyond traditional passwords.
Implement Infobip OTP/2FA during user registration or login to verify user identity, and for transaction confirmations to enhance security and prevent fraud. This adds an extra layer of protection beyond passwords, safeguarding sensitive actions within your app.
Yes, the Infobip Node.js SDK is compatible with both Next.js Pages Router and App Router. The core logic remains the same, but file paths and routing configuration might slightly differ based on the chosen routing approach.
Create a .env.local file in the project root to store your Infobip API key, Base URL, App ID, and Message ID. Next.js loads these into process.env server-side, keeping your secrets secure. Never commit .env.local to version control.
Configure 2FA in the Infobip portal by first getting your API key and Base URL. Next, create a 2FA application, set up its parameters like maximum attempts, and finally create a message template defining the SMS content with the {{pin}} placeholder.
Implement try...catch blocks in your API routes to handle errors during OTP sending and verification. Use a structured logger to record detailed error information server-side and return generic, user-friendly error messages to the client.
Protect API keys with environment variables, implement server-side input validation and rate limiting, consider secure pinId handling methods, use HTTPS, and understand CSRF risks. These measures safeguard against common vulnerabilities.
Test with Infobip's test numbers or sandbox if available, and conduct unit tests for API routes, mocking the Infobip SDK. Implement integration tests for frontend-backend interaction, and consider end-to-end tests for staging or production environments.
Your Infobip Base URL is region-specific and can be located on the homepage of your Infobip portal. It's essential for configuring the Infobip client and ensuring successful API requests.
The pinId, returned by Infobip after initiating an OTP send request, is crucial for verifying the user-entered OTP. It identifies a specific OTP transaction.
Ensure you have Node.js, npm/yarn, an active Infobip account, basic understanding of Next.js and React, and a text editor. These prerequisites are essential for setting up and integrating Infobip's 2FA service.
Sending the pinId to the client presents a security risk as it exposes this identifier. A more secure approach is to store it server-side and retrieve it based on the user's session during verification, preventing potential misuse.
Learn how to build secure SMS-based two-factor authentication (2FA) in Next.js applications using Infobip's API. This comprehensive tutorial covers implementing OTP verification for user registration, login security, and transaction confirmation with complete code examples and production-ready patterns.
⚠️ Critical Security Notice:
Before implementing SMS-based 2FA, understand these security limitations:
Recommended Alternatives: For higher security requirements, consider implementing TOTP (Time-based One-Time Password) apps, WebAuthn/FIDO2, or hardware security keys alongside or instead of SMS.
What You'll Build: A complete SMS OTP authentication system that sends verification codes via Infobip's messaging service and validates user input through secure API routes.
Real-World Use Cases:
Business Benefits:
Master the complete implementation process including Infobip API configuration, secure Next.js API route development, React component creation, and production deployment best practices. Perfect for developers building authentication systems, e-commerce checkout flows, or any application requiring phone number verification.
For more Next.js authentication patterns, check out our guide on implementing JWT authentication in Next.js and building secure user registration systems.
Building SMS Authentication: Project Overview
What We're Building:
We will implement a feature where a user can enter their phone number, receive an OTP via SMS powered by Infobip, and then verify that OTP within the Next.js application. This forms the core of a 2FA or phone verification system.
Problem Solved:
Compliance and Regulatory Requirements:
GDPR Compliance: Phone numbers constitute personal data requiring valid legal basis (consent or legitimate interest). For transactional OTP messages, these are considered necessary service communications and don't require advertising consent. However, you must implement proper data retention policies and honor data subject rights including data portability and erasure requests.
PCI-DSS Requirements: Payment processing systems often mandate multi-factor authentication for sensitive operations. SMS OTP satisfies PCI-DSS requirements for "something you have" authentication factors.
Data Retention: Under GDPR, organizations must define retention periods for personal data and automate deletion when retention periods expire or upon data subject request.
Enhanced Security: Adds a second layer of authentication beyond passwords, with ENISA recommending 2FA implementation for high-risk cases involving personal data processing.
User Verification: Confirms user identity by validating phone number ownership.
Fraud Prevention: Helps prevent fake account creation and secures transactions. Financial institutions report up to 99.9% reduction in account takeover attacks when implementing proper 2FA systems.
Technologies Involved:
System Architecture:
The flow involves three main components:
Prerequisites:
For additional Next.js setup guidance, see our Next.js development environment setup guide and React authentication patterns tutorial.
Security Prerequisites:
1. Next.js Project Setup for SMS Authentication
Follow these step-by-step instructions to set up your Next.js project with all required dependencies for SMS authentication.
1.1 Create Next.js Project:
Open your terminal and run:
npx create-next-app@latest infobip-otp-nextjs # Follow the prompts (e.g., choose TypeScript if desired, App Router or Pages Router) cd infobip-otp-nextjs1.2 Install Infobip Node.js SDK:
This SDK provides convenient methods for interacting with the Infobip API.
npm install infobip-api-nodejs-sdk # or yarn add infobip-api-nodejs-sdk1.3 Environment Variables:
We need to store sensitive Infobip credentials securely. Create a file named
.env.localin the root of your project. Never commit this file to version control.Essential .gitignore Setup:
First, ensure your
.gitignorefile includes all environment files:Create Environment Template:
For team collaboration, create
.env.examplewith the structure but no sensitive values:.env.local? Next.js automatically loads variables from this file intoprocess.envon the server side (API routes), keeping your secrets out of the frontend bundle and source control.1.4 Project Structure:
Your basic structure (using Pages Router for simplicity in examples, adapt if using App Router):
2. Infobip SMS API Configuration Guide
Before writing code, we need to configure the necessary components within the Infobip platform.
2.1 Obtain API Key and Base URL:
Log in to your Infobip Portal.
Base URL: On the homepage, locate your API Base URL. It's region-specific (e.g.,
xxxxx.api.infobip.com).Region Selection Guidance:
Copy this value into
INFOBIP_BASE_URLin your.env.local.API Key:
API Key Management. Note: The exact name and location of this section within the Infobip Portal UI might change over time. Look for settings related to API access or developer tools.Create API Key.Nextjs OTP App - Production).Submit. Copy the generated API key immediately, as it won't be shown again. Paste it intoINFOBIP_API_KEYin your.env.local.API Key Rotation Best Practices:
2.2 Create a 2FA Application:
This defines the rules for your OTP flow (e.g., how many attempts are allowed, how long the OTP is valid).
Appssection (or search for2FA Applications).Create Application.My Next.js App Verification).pinAttempts: Max number of verification attempts (recommended:3-5). Impact: Higher values increase user convenience but reduce security. Lower values may cause user frustration with legitimate typos.allowMultiplePinVerifications:false(recommended). Impact: Set tofalsefor security - each PIN can only be verified once. Setting totrueallows reuse of the same PIN multiple times within the TTL window.pinTimeToLive: How long the OTP is valid (recommended:5mfor 5 minutes). Impact: Shorter TTL increases security but may frustrate users. Longer TTL improves UX but extends vulnerability window.verifyPinLimit: Rate limit for verification attempts (recommended:1/3s- 1 attempt per 3 seconds). Impact: Prevents brute force attacks while allowing reasonable retry intervals.sendPinPerApplicationLimit: Global rate limit (e.g.,10000/1d- 10,000 per day). Cost Impact: This directly affects your SMS costs. At ~$0.0079 per US SMS, 10,000 daily messages ≈ $79/day or $2,400/month.sendPinPerPhoneNumberLimit: Per-number limit (recommended:3-5/1d- 3-5 per day per number). Impact: Prevents abuse while allowing legitimate retries. Higher limits increase spam potential and costs.true.Submit.applicationId). Paste this intoINFOBIP_2FA_APP_IDin your.env.local.2.3 Create a 2FA Message Template:
This defines the content of the SMS message sent to the user, including the OTP placeholder.
Message Templatessection.Create Message Template.NUMERIC(most common).{{pin}}placeholder where the OTP should be inserted. Example:Your verification code for My App is {{pin}}. It expires in 5 minutes.6). Remember this value for validation.InfoSMSor configure a custom one if available on your account.enfor English).Submit.messageId). Paste this intoINFOBIP_2FA_MESSAGE_IDin your.env.local.3. Building SMS OTP API Endpoints in Next.js
Now, let's create the backend endpoints that our frontend will call.
3.1 Initialize Infobip Client:
It's good practice to create a utility function or singleton instance for the Infobip client.
Create
lib/infobip.js(or.ts):// lib/infobip.js import { Infobip } from ""infobip-api-nodejs-sdk""; let infobipInstance = null; // Environment variable validation const validateEnvironment = () => { const required = { INFOBIP_BASE_URL: process.env.INFOBIP_BASE_URL, INFOBIP_API_KEY: process.env.INFOBIP_API_KEY, INFOBIP_2FA_APP_ID: process.env.INFOBIP_2FA_APP_ID, INFOBIP_2FA_MESSAGE_ID: process.env.INFOBIP_2FA_MESSAGE_ID }; const missing = Object.entries(required) .filter(([key, value]) => !value) .map(([key]) => key); if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } return required; }; export const getInfobipClient = () => { if (!infobipInstance) { validateEnvironment(); infobipInstance = new Infobip({ baseUrl: process.env.INFOBIP_BASE_URL, apiKey: process.env.INFOBIP_API_KEY, // Timeout configuration for production reliability timeout: 30000, // 30 seconds timeout // Optional: Add retry configuration // retryConfig: { // maxRetries: 2, // retryDelay: 1000 // 1 second between retries // } }); } return infobipInstance; }; // TypeScript version (create lib/infobip.ts instead): /* import { Infobip } from 'infobip-api-nodejs-sdk'; interface InfobipConfig { baseUrl: string; apiKey: string; timeout?: number; } interface RequiredEnvVars { INFOBIP_BASE_URL: string; INFOBIP_API_KEY: string; INFOBIP_2FA_APP_ID: string; INFOBIP_2FA_MESSAGE_ID: string; } let infobipInstance: Infobip | null = null; const validateEnvironment = (): RequiredEnvVars => { const config = { INFOBIP_BASE_URL: process.env.INFOBIP_BASE_URL, INFOBIP_API_KEY: process.env.INFOBIP_API_KEY, INFOBIP_2FA_APP_ID: process.env.INFOBIP_2FA_APP_ID, INFOBIP_2FA_MESSAGE_ID: process.env.INFOBIP_2FA_MESSAGE_ID }; const missing = Object.entries(config) .filter(([key, value]) => !value) .map(([key]) => key); if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } return config as RequiredEnvVars; }; export const getInfobipClient = (): Infobip => { if (!infobipInstance) { validateEnvironment(); const config: InfobipConfig = { baseUrl: process.env.INFOBIP_BASE_URL!, apiKey: process.env.INFOBIP_API_KEY!, timeout: 30000 }; infobipInstance = new Infobip(config); } return infobipInstance; }; */ // Note on Singletons and Serverless Environments: // This singleton pattern works well for typical Next.js server deployments. // However, in serverless environments (like Vercel Serverless Functions), // the lifecycle might lead to multiple instances or unexpected state retention // across invocations if not managed carefully. For simple use cases like this, // it's often acceptable, but be mindful of potential scaling or state issues // in complex serverless scenarios. Consider initializing the client per-request // if necessary, though this might have a slight performance overhead.3.2 API Route: Send OTP (
/api/send-otp)This route receives a phone number, uses the Infobip SDK to send an OTP, and returns the
pinIdneeded for verification.// pages/api/send-otp.js import { getInfobipClient } from ""../../lib/infobip""; // Consider using a robust phone number validation library for production // import { parsePhoneNumberFromString } from 'libphonenumber-js'; // Placeholder for a structured logger (replace console calls in production) // import logger from '../../lib/logger'; // Example: Your logger setup export default async function handler(req, res) { if (req.method !== ""POST"") { res.setHeader(""Allow"", [""POST""]); return res.status(405).json({ message: `Method ${req.method} Not Allowed` }); } const { phoneNumber } = req.body; // --- Phone Number Validation --- // Basic Regex (Insecure for Production): Allows 10-15 digits. Doesn't validate country codes. const basicPhoneNumberRegex = /^\d{10,15}$/; if (!phoneNumber || !basicPhoneNumberRegex.test(phoneNumber)) { // Production Recommendation: Use a library like libphonenumber-js // const parsedNumber = parsePhoneNumberFromString(phoneNumber); // Requires country code usually // if (!parsedNumber || !parsedNumber.isValid()) { // return res.status(400).json({ message: ""Valid E.164 phone number format required (e.g., +14155552671)."" }); // } // Using basic validation for this example: return res.status(400).json({ message: ""Valid phone number is required (10-15 digits, include country code)."" }); } // --- End Phone Number Validation --- if (!process.env.INFOBIP_2FA_APP_ID || !process.env.INFOBIP_2FA_MESSAGE_ID) { // logger.error(""Server configuration error: Infobip App ID or Message ID missing.""); // Use logger console.error(""Server configuration error: Infobip App ID or Message ID missing.""); return res.status(500).json({ message: ""Server configuration error."" }); } try { const infobip = getInfobipClient(); // logger.info(`Attempting to send OTP to ${phoneNumber}`); // Use logger const response = await infobip.channels.sms.sendTfaPin({ applicationId: process.env.INFOBIP_2FA_APP_ID, messageId: process.env.INFOBIP_2FA_MESSAGE_ID, // 'from' can often be omitted if defined in the template or account defaults // from: ""YourSenderID"", to: phoneNumber, // Assumes phoneNumber includes country code from input // Optional: Define placeholder values if your template uses them beyond {{pin}} // placeholders: { ""firstName"": ""User"" } }); // Successfully initiated OTP send // logger.info({ msg: ""Infobip Send OTP Response OK"", pinId: response.data.pinId }); // Use logger console.log(""Infobip Send OTP Response:"", response.data); // Basic logging // IMPORTANT: Return the pinId to the client for the verification step return res.status(200).json({ pinId: response.data.pinId }); } catch (error) { const errorData = error.response ? error.response.data : error.message; // logger.error({ msg: ""Error sending OTP via Infobip"", error: errorData }); // Use logger console.error(""Error sending OTP via Infobip:"", errorData); // Basic logging // Provide a generic error to the client, log specifics server-side let statusCode = 500; let message = ""Failed to send OTP.""; // Handle specific Infobip error codes if needed if (error.response && error.response.data && error.response.data.requestError) { const serviceException = error.response.data.requestError.serviceException; if (serviceException) { message = serviceException.text || message; // You could map specific messageIds (like rate limits) to 429 status if (serviceException.messageId === 'TOO_MANY_REQUESTS') { statusCode = 429; } // Add other specific error mappings if desired } } else if (error.response && error.response.status) { statusCode = error.response.status; // Use status from Infobip if available } return res.status(statusCode).json({ message }); } }3.3 API Route: Verify OTP (
/api/verify-otp)This route receives the
pinId(obtained from the send step) and thepinentered by the user. It calls Infobip to verify.// pages/api/verify-otp.js import { getInfobipClient } from ""../../lib/infobip""; // Placeholder for a structured logger (replace console calls in production) // import logger from '../../lib/logger'; // Example: Your logger setup export default async function handler(req, res) { if (req.method !== ""POST"") { res.setHeader(""Allow"", [""POST""]); return res.status(405).json({ message: `Method ${req.method} Not Allowed` }); } const { pinId, pin } = req.body; // --- Input Validation --- if (!pinId) { return res.status(400).json({ message: ""Pin ID is required."" }); } // PIN Validation: // The regex below assumes a PIN length between 4 and 10 digits. // Ideally, this should match the 'PIN Length' configured in your // Infobip 2FA Message Template (Section 2.3). // Consider making this dynamic or ensuring it matches your config. const pinRegex = /^\d{4,10}$/; // Example: Adjust '4' and '10' to match your template's PIN length if (!pin || !pinRegex.test(pin)) { return res.status(400).json({ message: ""Valid PIN format required."" }); } // --- End Input Validation --- try { const infobip = getInfobipClient(); // logger.info(`Attempting to verify OTP for pinId: ${pinId}`); // Use logger const response = await infobip.channels.sms.verifyTfaPin(pinId, { pin: pin }); // logger.info({ msg: ""Infobip Verify OTP Response OK"", pinId: pinId, verified: response.data.verified }); // Use logger console.log(""Infobip Verify OTP Response:"", response.data); // Basic logging if (response.data.verified) { // OTP Verified Successfully! // Perform actions here: e.g., log user in, complete registration, update database flag // logger.info(`OTP verification successful for pinId: ${pinId}`); // Use logger return res.status(200).json({ verified: true, message: ""OTP verified successfully."" }); } else { // OTP Incorrect or Expired (or other non-verified state) // Note: Infobip might return a 200 OK with verified: false for incorrect PINs // within the attempt limit. Errors (4xx/5xx) usually indicate other issues. // logger.warn(`OTP verification failed for pinId: ${pinId} - Not verified by Infobip.`); // Use logger return res.status(400).json({ verified: false, message: ""Invalid or expired OTP."" }); } } catch (error) { const errorData = error.response ? error.response.data : error.message; // logger.error({ msg: ""Error verifying OTP via Infobip"", pinId: pinId, error: errorData }); // Use logger console.error(""Error verifying OTP via Infobip:"", errorData); // Basic logging let statusCode = 500; let message = ""Failed to verify OTP.""; let verified = false; // Check for specific verification errors (e.g., wrong PIN, expired, max attempts) if (error.response && error.response.data && error.response.data.requestError) { const serviceException = error.response.data.requestError.serviceException; if (serviceException) { message = serviceException.text || message; // Use Infobip's error text // Map specific errors if needed, e.g., differentiate between wrong PIN and expired PIN if (serviceException.messageId === 'PIN_NOT_FOUND') { message = 'OTP session not found or expired.'; statusCode = 404; // Not Found is appropriate here } else if (serviceException.messageId === 'WRONG_PIN') { message = 'Invalid OTP provided.'; statusCode = 400; // Bad Request (client error) } else if (serviceException.messageId === 'MAX_ATTEMPTS_EXCEEDED') { message = 'Maximum verification attempts reached.'; statusCode = 429; // Too Many Requests } else { // Use Infobip's status code if available and seems appropriate statusCode = error.response.status >= 400 ? error.response.status : 400; } } } else if (error.response && error.response.status) { statusCode = error.response.status; } return res.status(statusCode).json({ verified, message }); } }API Testing (Example using
curl):Replace placeholders with actual values. Remember to start your Next.js development server (
npm run devoryarn dev).Send OTP:
curl -X POST http://localhost:3000/api/send-otp \ -H ""Content-Type: application/json"" \ -d '{""phoneNumber"": ""+14155552671""}' # Use a real number you can receive SMS on, including country codeExpected Response (Success):
{""pinId"":""SOME_PIN_ID_FROM_INFOBIP""}Expected Response (Error):{ ""message"": ""Specific error message..."" }(with appropriate status code)Verify OTP (using
pinIdfrom above and the received SMS code):curl -X POST http://localhost:3000/api/verify-otp \ -H ""Content-Type: application/json"" \ -d '{""pinId"": ""SOME_PIN_ID_FROM_INFOBIP"", ""pin"": ""123456""}' # Replace 123456 with the actual OTPExpected Response (Success):
{""verified"": true, ""message"": ""OTP verified successfully.""}Expected Response (Invalid PIN):{""verified"": false, ""message"": ""Invalid or expired OTP.""}(or more specific error)4. Creating the OTP Verification UI Components
Now, let's create a simple UI for users to interact with our OTP flow.
// pages/index.js import React, { useState } from 'react'; // Frontend Styling Note: // This example uses basic inline styles (`style={{ ... }}`) for simplicity. // For production applications, it's highly recommended to use more maintainable // styling solutions like CSS Modules, Tailwind CSS, styled-components, or Emotion, // which integrate well with Next.js. export default function OtpPage() { const [phoneNumber, setPhoneNumber] = useState(''); const [pinId, setPinId] = useState(''); // State to hold the pinId from the backend const [otp, setOtp] = useState(''); const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isOtpSent, setIsOtpSent] = useState(false); // Controls which form part is shown const [isVerified, setIsVerified] = useState(false); // Controls final success message const handleSendOtp = async (e) => { e.preventDefault(); setMessage(''); setIsLoading(true); setIsVerified(false); setIsOtpSent(false); // Reset OTP state setPinId(''); // Reset pinId // Frontend Input Consideration: // While backend validation is essential, consider adding basic frontend // format masking (e.g., using react-imask) or validation for the phone number // input to improve user experience and reduce invalid submissions. try { const res = await fetch('/api/send-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber }), }); const data = await res.json(); if (res.ok) { setPinId(data.pinId); // Store the pinId received from the API setIsOtpSent(true); setMessage('OTP sent successfully! Please check your phone.'); } else { setMessage(`Error: ${data.message || 'Failed to send OTP'}`); setIsOtpSent(false); } } catch (error) { console.error(""Frontend Error Sending OTP:"", error); setMessage('An unexpected error occurred. Please try again.'); setIsOtpSent(false); } finally { setIsLoading(false); } }; const handleVerifyOtp = async (e) => { e.preventDefault(); setMessage(''); setIsLoading(true); try { const res = await fetch('/api/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinId, pin: otp }), // Send pinId and the entered OTP }); const data = await res.json(); if (res.ok && data.verified) { setIsVerified(true); setMessage('Success! Phone number verified.'); // Optionally reset the form or navigate the user setPinId(''); setOtp(''); setIsOtpSent(false); // Go back to phone number input stage for next time // setPhoneNumber(''); // Optional: clear phone number too } else { // Handle verification failure (invalid OTP, expired, etc.) setMessage(`Error: ${data.message || 'Failed to verify OTP'}`); setIsVerified(false); setOtp(''); // Clear OTP input field for retry attempt } } catch (error) { console.error(""Frontend Error Verifying OTP:"", error); setMessage('An unexpected error occurred during verification.'); setIsVerified(false); } finally { setIsLoading(false); } }; // Handler for the ""Change/Resend"" button const handleResetFlow = () => { setIsOtpSent(false); // Go back to the phone number input stage setMessage(''); setOtp(''); setPinId(''); setIsVerified(false); // The phone number input remains populated, allowing easy resend or correction. }; return ( <div style={{ maxWidth: '400px', margin: '50px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}> <h1>Phone Verification</h1> {/* Show forms only if not successfully verified */} {!isVerified && ( <form onSubmit={!isOtpSent ? handleSendOtp : handleVerifyOtp}> {!isOtpSent ? ( // Stage 1: Enter Phone Number <> <label htmlFor=""phoneNumber"" style={{ display: 'block', marginBottom: '5px' }}>Phone Number:</label> <input type=""tel"" // Use ""tel"" type for better mobile UX id=""phoneNumber"" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder=""e.g., +14155552671 (with country code)"" required disabled={isLoading} style={{ width: '100%', padding: '8px', marginBottom: '10px', boxSizing: 'border-box' }} /> <button type=""submit"" disabled={isLoading} style={{ width: '100%', padding: '10px' }}> {isLoading ? 'Sending...' : 'Send OTP'} </button> </> ) : ( // Stage 2: Enter OTP <> <p>Enter the OTP sent to {phoneNumber}.</p> <label htmlFor=""otp"" style={{ display: 'block', marginBottom: '5px' }}>OTP:</label> <input type=""text"" // Use ""text"" and pattern/maxLength for better control inputMode=""numeric"" // Hint for numeric keyboard on mobile id=""otp"" value={otp} onChange={(e) => setOtp(e.target.value)} maxLength={6} // Match your PIN length configured in Infobip pattern=""\d*"" // Allow only digits required disabled={isLoading} autoComplete=""one-time-code"" // Helps browsers/password managers autofill OTP style={{ width: '100%', padding: '8px', marginBottom: '10px', boxSizing: 'border-box' }} /> <button type=""submit"" disabled={isLoading} style={{ width: '100%', padding: '10px', marginBottom: '10px' }}> {isLoading ? 'Verifying...' : 'Verify OTP'} </button> {/* Button to go back to phone input / allow resend */} <button type=""button"" onClick={handleResetFlow} disabled={isLoading} style={{ width: '100%', padding: '10px', background: '#eee', border: '1px solid #ccc', cursor: 'pointer' }} > Change Phone Number / Resend OTP </button> </> )} </form> )} {/* Display messages (errors or success) */} {message && ( <p style={{ marginTop: '15px', color: isVerified ? 'green' : (message.startsWith('Error:') ? 'red' : 'black') }}> {message} </p> )} </div> ); }5. Production Error Handling and Monitoring
try...catchblocks.console.logandconsole.errorwith a dedicated, structured logging library (e.g., Pino, Winston) or integrate with a logging service (e.g., Sentry, Logtail, Datadog). This allows for better filtering, searching, and alerting in production. Log detailed error information server-side, including Infobip's response data when available (error.response.data).429, invalid credentials401, etc.) by checkingerror.response.statusanderror.response.data.requestError.serviceException.messageId.try...catcharoundfetchcalls. Display clear messages to the user based on API responses. Provide ways to retry (like the ""Resend OTP"" button) or restart the process.async-retry). Be cautious with retrying OTP sends to avoid spamming users or hitting rate limits quickly. Exponential backoff is generally recommended for retrying external API calls.6. SMS Authentication Security Best Practices
.env.localfor local development) and never expose your API key in frontend code or commit it to version control. Configure restricted permissions (least privilege principle) and consider IP allowlisting for your Infobip API key in production.verifyPinLimit,sendPinPerPhoneNumberLimit,sendPinPerApplicationLimit) in your Infobip 2FA Application settings to prevent abuse at the source./api/send-otpand/api/verify-otpendpoints to protect your own server resources. Libraries likerate-limiter-flexibleorexpress-rate-limit(adapted for Next.js API routes) can be used. Hosting platforms like Vercel also offer built-in rate limiting features.pinIdpresence) on the server-side (API routes). Do not rely solely on frontend validation, as it can be bypassed. Use robust libraries likelibphonenumber-jsfor phone numbers.pinIdto the client and back. This is simpler to implement but less secure, as thepinIdis exposed. An attacker who obtains apinIdcould potentially attempt to guess the PIN by directly calling your/api/verify-otpendpoint (though Infobip's rate limits offer protection).pinIdto the client. Instead, store thepinIdserver-side immediately after the/api/send-otpcall. Associate it with the user's session (e.g., usingnext-author encrypted cookies) or a temporary secure cache (like Redis) keyed by session ID or phone number, with a TTL matching Infobip'spinTimeToLive. When the user submits the OTP to/api/verify-otp, retrieve the correctpinIdfrom the server-side storage based on their session/context and then call Infobip. This preventspinIdexposure but adds server-side state management complexity.pinAttemptssetting provides primary protection against guessing the OTP. Your API route rate limiting adds another layer of defense against hammering the verification endpoint.7. Testing SMS OTP Implementation
send-otp.js,verify-otp.js). Mock theinfobip-api-nodejs-sdkusingjest.mockor similar techniques to simulate:pinId).verified: true).verified: false).pinId).fetchcalls in your frontend tests or run against a live development server (potentially mocking the Infobip SDK at the API level).This validates the entire system, including the actual Infobip integration, but can be more complex and costly to set up and run.
8. Deploying Your Next.js OTP System to Production
When deploying your SMS OTP authentication system, consider these platform-specific configurations:
Vercel Deployment (Recommended for Next.js):
INFOBIP_*variables in Vercel dashboard under Project Settings → Environment VariablesEnvironment Variable Management:
# Production deployment checklist vercel env add INFOBIP_BASE_URL production vercel env add INFOBIP_API_KEY production vercel env add INFOBIP_2FA_APP_ID production vercel env add INFOBIP_2FA_MESSAGE_ID productionPerformance Optimization:
Monitoring and Alerting:
9. Common Issues and Troubleshooting
Issue: "Missing required environment variables" error
.env.localand properly loaded by Next.jsIssue: SMS not received by users
Issue: "Invalid credentials" or 401 errors
.env.localxxxxx.api.infobip.com)Issue: Rate limit errors (429 status)
sendPinPerPhoneNumberLimitandsendPinPerApplicationLimitsettings in Infobip 2FA ApplicationIssue: OTP verification always fails
pinTimeToLivesetting—OTP may be expiring too quicklypinIdis correctly passed from send to verify API route10. Next Steps and Advanced Features
Enhance Your Implementation:
Security Hardening:
pinIdmanagementUser Experience Improvements:
navigator.credentials.get())Related Guides: