Developer Guide: Implementing Infobip OTP/2FA in Next.js with Node.js
This guide provides a complete walkthrough for integrating Infobip's Two-Factor Authentication (2FA) service into your Next.js application using API routes for the backend logic. We'll build a secure and reliable One-Time Password (OTP) verification flow suitable for user registration, login verification, or transaction confirmation.
By following this guide, you'll learn how to configure Infobip, create necessary API endpoints in Next.js, handle the OTP sending and verification process, and manage security considerations for a production-ready implementation.
Project Overview and Goals
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:
- Enhanced Security: Adds a second layer of authentication beyond passwords.
- User Verification: Confirms user identity by validating phone number ownership.
- Fraud Prevention: Helps prevent fake account creation and secures transactions.
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.
[User's Browser: Frontend UI] <---> [Next.js Server: API Routes] <---> [Infobip API]
(Enter Phone #) -----------> /api/send-otp ------------> (Send OTP Request)
<------------ (pinId)
<----------- (pinId received)
(Receive SMS_ Enter OTP) ---> /api/verify-otp ---------> (Verify OTP Request)
(with pinId & OTP) <------------ (Verification Result)
<----------- (Success/Failure)
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- 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 (e.g._ VS Code).
1. Setting up the Project
Let's initialize a new Next.js project and install the necessary dependency.
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-nextjs
1.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-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.
# .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 intoprocess.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):
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. Configuring Infobip 2FA
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
). Copy this value intoINFOBIP_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
). - Assign Roles/Permissions:
- Least Privilege Principle: For production environments, it is strongly recommended to grant only the minimum permissions necessary for this key to function. Avoid granting full access unless absolutely required.
- For this 2FA flow, the key typically needs permissions related to
2FA Send PIN
and2FA Verify PIN
. Check the Infobip documentation for the exact permission names. - During initial development, you might temporarily use broader permissions, but ensure you restrict them before going live.
- Optionally add IP restrictions for enhanced security in production.
- Click
Submit
. Copy the generated API key immediately, as it won't be shown again. Paste it intoINFOBIP_API_KEY
in your.env.local
.
- Navigate to
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 for2FA Applications
). - Click
Create Application
. - Configure the settings:
- Application Name: Give it a clear name (e.g.,
My Next.js App Verification
). - Configuration:
pinAttempts
: Max number of verification attempts (e.g.,5
).allowMultiplePinVerifications
:true
orfalse
. Usuallyfalse
for security (verify only once).pinTimeToLive
: How long the OTP is valid (e.g.,5m
for 5 minutes).verifyPinLimit
: Rate limit for verification attempts (e.g.,1/3s
- 1 attempt per 3 seconds).sendPinPerApplicationLimit
: Rate limit for sending PINs globally for this app (e.g.,10000/1d
- 10000 per day).sendPinPerPhoneNumberLimit
: Rate limit for sending PINs to a single number (e.g.,5/1d
- 5 per day per number).
- Enabled: Ensure it's set to
true
.
- Application Name: Give it a clear name (e.g.,
- Click
Submit
. - Copy the generated Application ID (
applicationId
). Paste this intoINFOBIP_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).
- PIN Type:
- Click
Submit
. - Copy the generated Message ID (
messageId
). Paste this intoINFOBIP_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. Implementing the API Layer (Next.js API Routes)
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;
export const getInfobipClient = () => {
if (!infobipInstance) {
if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) {
throw new Error(""Infobip Base URL or API Key is missing in environment variables."");
}
infobipInstance = new Infobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
// Optional: Add other configurations like application ID if needed globally,
// but often better to pass them per request.
});
}
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.
// 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 the pin
entered 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 dev
or yarn 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 code
Expected Response (Success):
{""pinId"":""SOME_PIN_ID_FROM_INFOBIP""}
Expected Response (Error):{ ""message"": ""Specific error message..."" }
(with appropriate status code) -
Verify OTP (using
pinId
from 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 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. Implementing the Frontend (React/Next.js Page)
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. Error Handling and Logging
- API Routes: We've included
try...catch
blocks.- Production Logging: Replace
console.log
andconsole.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 credentials401
, etc.) by checkingerror.response.status
anderror.response.data.requestError.serviceException.messageId
.
- Production Logging: Replace
- Frontend: Use
try...catch
aroundfetch
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. Security Considerations
- 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 likerate-limiter-flexible
orexpress-rate-limit
(adapted for Next.js API routes) can be used. Hosting platforms like Vercel also offer built-in rate limiting features.
- Infobip: Configure sensible limits (
- 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 likelibphonenumber-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 thepinId
is exposed. An attacker who obtains apinId
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 thepinId
server-side immediately after the/api/send-otp
call. Associate it with the user's session (e.g., usingnext-auth
or 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 correctpinId
from the server-side storage based on their session/context and then call Infobip. This preventspinId
exposure but adds server-side state management complexity.
- Current Implementation: This guide sends the
- 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
- 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 theinfobip-api-nodejs-sdk
usingjest.mock
or similar techniques to simulate:- Successful OTP send responses (returning a mock
pinId
). - Successful OTP verification responses (
verified: true
). - Failed OTP verification responses (
verified: false
). - 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
).
- Successful OTP send responses (returning a mock
- 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.