Enhance your application's security by adding a robust Two-Factor Authentication (2FA) layer using Infobip's OTP service. This guide provides a complete walkthrough for integrating Infobip 2FA into a system with a Vite (React) frontend and a Node.js backend.
We'll build a simple application where a user can enter their phone number, receive an OTP via SMS powered by Infobip, and verify that OTP to gain access or confirm an action. This guide focuses on the core OTP request and verification flow.
Project Overview and Goals
-
Problem Solved: Secure user accounts and critical actions by verifying possession of a phone number through OTP, mitigating risks associated with compromised passwords.
-
Technologies Used:
- Frontend: Vite + React (Fast, modern UI development)
- Backend: Node.js + Express (Efficient, scalable JavaScript runtime and web framework)
- OTP Provider: Infobip (Reliable, global communication platform with robust 2FA APIs)
- HTTP Client: Axios (Promise-based HTTP client for browser and Node.js)
-
Architecture:
+-----------------+ +---------------------+ +-----------------+ | Vite/React App |----->| Node.js/Express API |----->| Infobip API | | (User Interface)| | (Backend Logic) |<-----| (SMS OTP Service)| +-----------------+ +---------------------+ +-----------------+ | ^ | ^ | | User Interaction | | API Calls v | v | User Enters Phone # Sends OTP Request User Enters OTP Verifies OTP
-
Outcome: A functional demonstration featuring a React frontend allowing users to request and verify OTPs sent via Infobip_ orchestrated by a Node.js backend API.
-
Prerequisites:
- Node.js and npm (or yarn) installed.
- An active Infobip account with API access.
- Basic understanding of React_ Node.js_ Express_ and REST APIs.
- A text editor or IDE (e.g._ VS Code).
- A tool for testing APIs (e.g._ Postman_ curl).
1. Setting Up the Project
We'll create a monorepo structure with separate directories for the client (Vite/React) and server (Node.js).
1. Create Project Directory:
mkdir infobip-2fa-app
cd infobip-2fa-app
2. Set Up Backend (Node.js/Express):
# Create server directory and navigate into it
mkdir server
cd server
# Initialize Node.js project
npm init -y
# Install dependencies
npm install express dotenv cors axios
# Create main server file and .env file
touch server.js .env
# Create a simple .gitignore file
echo node_modules > .gitignore
echo .env >> .gitignore
express
: Web framework for Node.js.dotenv
: Loads environment variables from a.env
file.cors
: Enables Cross-Origin Resource Sharing (necessary for frontend interaction).axios
: Used to make HTTP requests to the Infobip API.
Project Structure (Server):
infobip-2fa-app/
└── server/
├── node_modules/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
3. Set Up Frontend (Vite/React):
Navigate back to the root directory (infobip-2fa-app
):
cd ..
# Create Vite/React project in a 'client' directory
npm create vite@latest client -- --template react
# Navigate into the client directory
cd client
# Install dependencies (including axios for API calls)
npm install axios
# Add a .gitignore if not already present (Vite usually adds one)
# Ensure node_modules/ and potentially dist/ are ignored
Project Structure (Client):
infobip-2fa-app/
├── client/
│ ├── node_modules/
│ ├── public/
│ ├── src/
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── package-lock.json
│ └── vite.config.js
└── server/
└── ... (server files)
4. Configure Environment Variables (Backend):
Open the server/.env
file and add placeholders for your Infobip credentials and configuration. You'll obtain these values later.
# server/.env
# Infobip Credentials & Configuration
INFOBIP_BASE_URL= # Example: your_instance.api.infobip.com (Find in Infobip Portal)
INFOBIP_API_KEY= # Your Infobip API Key (Generate in Infobip Portal)
INFOBIP_APP_ID= # Your Infobip 2FA Application ID (Create in Infobip Portal)
INFOBIP_MSG_ID= # Your Infobip 2FA Message Template ID (Create in Infobip Portal)
# Server Configuration
PORT=3001 # Port for the backend server
- Purpose: Storing sensitive credentials and configuration outside your codebase is crucial for security and maintainability.
dotenv
makes these accessible viaprocess.env
.
2. Building the Backend API Layer
We'll create two endpoints in our Express server: one to request an OTP and another to verify it.
Edit server/server.js
:
// server/server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const app = express();
const port = process.env.PORT || 3001;
// --- Middleware ---
// Enable CORS for requests from your frontend
// IMPORTANT: In production, replace 'http://localhost:5173' with your specific frontend domain(s) for security!
app.use(cors({ origin: 'http://localhost:5173' }));
app.use(express.json()); // Parse JSON request bodies
// --- Infobip Configuration ---
const INFOBIP_BASE_URL = process.env.INFOBIP_BASE_URL;
const INFOBIP_API_KEY = process.env.INFOBIP_API_KEY;
const INFOBIP_APP_ID = process.env.INFOBIP_APP_ID;
const INFOBIP_MSG_ID = process.env.INFOBIP_MSG_ID;
// Basic validation function (enhance as needed)
const validatePhoneNumber = (phoneNumber) => {
// Example: Simple check for E.164 format (digits only, optional +)
// Infobip generally requires E.164 format (e.g., 447 NNNNNNNNN)
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
return phoneRegex.test(phoneNumber);
};
// --- API Endpoints ---
/**
* @route POST /api/otp/request
* @desc Request an OTP code to be sent to a phone number via Infobip.
* @access Public
* @body { `` `phoneNumber` ``: `` `E.164_formatted_number` `` }
*/
app.post('/api/otp/request', async (req, res) => {
const { phoneNumber } = req.body;
// 1. Validation
if (!phoneNumber || !validatePhoneNumber(phoneNumber)) {
return res.status(400).json({ success: false, message: 'Invalid phone number format. Use E.164 format (e.g., 447123456789).' });
}
if (!INFOBIP_BASE_URL || !INFOBIP_API_KEY || !INFOBIP_APP_ID || !INFOBIP_MSG_ID) {
console.error('Infobip configuration missing in environment variables.');
return res.status(500).json({ success: false, message: 'Server configuration error.' });
}
// 2. Prepare Infobip API Request
// NOTE: Ensure these endpoints match the current Infobip 2FA API documentation.
const infobipUrl = `https://${INFOBIP_BASE_URL}/2fa/2/pin`;
const requestData = {
applicationId: INFOBIP_APP_ID,
messageId: INFOBIP_MSG_ID,
to: phoneNumber,
// 'from' can be optionally set here if needed and configured in Infobip
};
const requestConfig = {
headers: {
'Authorization': `App ${INFOBIP_API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
};
// 3. Send Request to Infobip
try {
console.log(`Requesting OTP for ${phoneNumber} using App ID: ${INFOBIP_APP_ID}, Msg ID: ${INFOBIP_MSG_ID}`);
const response = await axios.post(infobipUrl, requestData, requestConfig);
console.log('Infobip Request OTP Response Status:', response.status);
console.log('Infobip Request OTP Response Data:', response.data);
// !!! CRITICAL SECURITY WARNING - DEMO ONLY !!!
// In a real production application, NEVER return the pinId to the client.
// The pinId is a temporary secret used to link the OTP request to the verification attempt.
// It should be stored securely on the SERVER-SIDE (e.g., in the user's session,
// a secure cache like Redis, or a temporary database record) associated with the
// user who initiated the request. When the user submits the OTP for verification,
// the backend should retrieve the correct pinId from its secure storage based on the
// user's session/context to verify the OTP. Exposing the pinId to the client
// creates significant security risks, potentially allowing attackers to bypass
// OTP verification under certain conditions (e.g., if they can manipulate or guess pinIds).
const pinId = response.data.pinId; // For demo purposes ONLY
res.status(200).json({
success: true,
message: 'OTP requested successfully.',
pinId: pinId // XXX DEMO ONLY - DO NOT SEND IN PRODUCTION XXX
});
} catch (error) {
console.error('Error requesting OTP from Infobip:');
if (error.response) {
// Infobip responded with an error status
console.error('Status:', error.response.status);
console.error('Data:', error.response.data);
const errorMsg = error.response.data?.requestError?.serviceException?.text || 'Failed to request OTP.';
res.status(error.response.status).json({ success: false, message: errorMsg });
} else if (error.request) {
// Request was made but no response received
console.error('Request Error:', error.request);
res.status(500).json({ success: false, message: 'No response from Infobip.' });
} else {
// Something else happened
console.error('Error:', error.message);
res.status(500).json({ success: false, message: 'An unexpected error occurred.' });
}
}
});
/**
* @route POST /api/otp/verify
* @desc Verify an OTP code using the pinId obtained during the request phase.
* @access Public
* @body { `` `pinId` ``: `` `infobip_pin_id` ``, `` `otp` ``: `` `user_entered_code` `` }
*/
app.post('/api/otp/verify', async (req, res) => {
// !!! SECURITY WARNING !!!
// This endpoint receives the pinId from the client because the /request endpoint
// insecurely returned it (for demo purposes). In production, the pinId should be
// retrieved from the secure server-side storage associated with the user's session,
// NOT passed directly from the client request body.
const { pinId, otp } = req.body;
// 1. Validation
if (!pinId || !otp) {
return res.status(400).json({ success: false, message: 'Missing pinId or OTP code.' });
}
if (!INFOBIP_BASE_URL || !INFOBIP_API_KEY ) {
console.error('Infobip configuration missing in environment variables.');
return res.status(500).json({ success: false, message: 'Server configuration error.' });
}
// 2. Prepare Infobip API Request
// NOTE: Ensure this endpoint matches the current Infobip 2FA API documentation.
const infobipUrl = `https://${INFOBIP_BASE_URL}/2fa/2/pin/${pinId}/verify`;
const requestData = {
pin: otp,
};
const requestConfig = {
headers: {
'Authorization': `App ${INFOBIP_API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
};
// 3. Send Request to Infobip
try {
console.log(`Verifying OTP for pinId: ${pinId}`);
const response = await axios.post(infobipUrl, requestData, requestConfig);
console.log('Infobip Verify OTP Response Status:', response.status);
console.log('Infobip Verify OTP Response Data:', response.data);
if (response.data.verified) {
// OTP is correct!
// In a real app: Clear the server-side pinId storage for this session.
// Grant access, complete the action, issue session token, etc.
res.status(200).json({ success: true, message: 'OTP verified successfully.' });
} else {
// This path might not always be hit if Infobip returns 4xx for invalid codes directly.
res.status(400).json({ success: false, message: 'OTP verification failed. Code might be invalid or expired.' });
}
} catch (error) {
console.error('Error verifying OTP with Infobip:');
if (error.response) {
console.error('Status:', error.response.status);
console.error('Data:', error.response.data);
let message = 'OTP verification failed.';
// Check specific Infobip error codes for better user feedback
if (error.response.status === 400 && error.response.data?.requestError?.serviceException?.messageId === 'PIN_NOT_FOUND') {
message = 'Invalid or expired OTP session. Please request a new code.';
} else if (error.response.status === 400 && error.response.data?.requestError?.serviceException?.messageId === 'WRONG_PIN') {
message = 'Incorrect OTP code. Please try again.';
} else if (error.response.status === 400 && error.response.data?.requestError?.serviceException?.messageId === 'MAX_ATTEMPTS_EXCEEDED') {
message = 'Maximum verification attempts exceeded. Please request a new code.';
} else {
// Use generic text from Infobip if available, otherwise fallback
message = error.response.data?.requestError?.serviceException?.text || message;
}
res.status(error.response.status).json({ success: false, message: message });
} else if (error.request) {
console.error('Request Error:', error.request);
res.status(500).json({ success: false, message: 'No response from Infobip verification.' });
} else {
console.error('Error:', error.message);
res.status(500).json({ success: false, message: 'An unexpected error occurred during verification.' });
}
}
});
// --- Start Server ---
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
console.log(`Expecting frontend at http://localhost:5173`); // Log expected Vite port
});
- Key Decisions:
- Used
axios
for Infobip API calls due to its simplicity and promise-based nature. - Implemented basic phone number validation (E.164 recommended). Robust validation is crucial in production.
- Included essential headers (
Authorization
,Content-Type
,Accept
) as required by Infobip. - Added basic error handling to catch issues during the API calls and return meaningful responses to the client.
- CRITICAL SECURITY WARNING: The
/api/otp/request
endpoint in this demonstration returns thepinId
directly to the client. DO NOT DO THIS IN A PRODUCTION ENVIRONMENT. ThepinId
must be stored securely on the server (e.g., associated with the user's session) and never exposed client-side. This example code prioritizes simplicity for demonstration over production security practices regardingpinId
handling. See the code comments for more details.
- Used
3. Integrating with Infobip
This is the core of the 2FA process. You need to configure Infobip and retrieve the necessary credentials.
1. Sign Up/Log In to Infobip:
- Go to infobip.com and create an account or log in.
2. Obtain Base URL and API Key:
- Once logged in, navigate to the Infobip API documentation or your account settings. Look for your Base URL (e.g.,
your_unique_id.api.infobip.com
) and generate an API Key. - Navigation (Example - may change): Often found under ""API Keys"" or ""Developer Settings"" sections in the portal.
- Action: Copy the Base URL and the generated API Key.
- Update
.env
: Paste these values intoserver/.env
forINFOBIP_BASE_URL
andINFOBIP_API_KEY
. Treat the API Key like a password—keep it secret!
3. Create a 2FA Application:
- In the Infobip portal, find the ""Apps"" or ""2FA"" section.
- Click to create a new Application.
- Configure the application settings:
- Name: Give it a descriptive name (e.g., ""My Vite App 2FA"").
- PIN Attempts: Maximum number of verification attempts (e.g., 5).
- PIN Time To Live (TTL): How long the OTP is valid (e.g.,
5m
for 5 minutes). - Rate Limits: Configure sending limits per application/phone number as needed (e.g.,
sendPinPerPhoneNumberLimit: 5/1d
- 5 pins per number per day). - Ensure the application is Enabled.
- Action: Save the application. Infobip will provide an Application ID (
applicationId
). - Update
.env
: Paste this ID intoserver/.env
forINFOBIP_APP_ID
.
4. Create a 2FA Message Template:
- Within the Application you just created (or in a related ""Message Templates"" section), create a new Message Template.
- Configure the template:
- PIN Type:
NUMERIC
(most common). - PIN Length: (e.g., 6). Note this value for the frontend.
- Message Text: The SMS body. Crucially, include the
{{pin}}
placeholder where Infobip will insert the generated OTP. Example:Your verification code for My App is: {{pin}}. It expires in 5 minutes.
- Sender ID: Choose a configured Sender ID (e.g., a short code or alphanumeric sender approved by Infobip). Using a generic one like ""Infobip"" might work for testing.
- PIN Type:
- Action: Save the message template. Infobip will provide a Message Template ID (
messageId
). - Update
.env
: Paste this ID intoserver/.env
forINFOBIP_MSG_ID
.
Now your server/.env
file should have all the necessary values filled in.
4. Implementing the Frontend (Vite/React)
Let's create a simple React component to interact with our backend API.
1. Configure Vite Proxy (Optional but Recommended for Development):
To avoid CORS issues during development without complex server configuration, you can proxy API requests through Vite's dev server.
Edit client/vite.config.js
:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173, // Ensure this matches the CORS origin in server.js
proxy: {
// Proxy /api requests to our backend server
'/api': {
target: 'http://localhost:3001', // Your backend server address
changeOrigin: true, // Needed for virtual hosted sites
// secure: false, // Uncomment if your backend uses http (not recommended for prod)
// rewrite: (path) => path.replace(/^\/api/, ''), // Uncomment if you want to remove /api prefix when forwarding
}
}
}
})
- Why Proxy? The browser normally restricts web pages from making requests to a different domain (origin) than the one that served the page. The proxy makes it appear to the browser that the API requests are coming from the same origin (
http://localhost:5173
).
2. Create the Authentication Component:
Replace the contents of client/src/App.jsx
with the following:
// client/src/App.jsx
import React, { useState } from 'react';
import axios from 'axios';
import './App.css'; // You can add some basic styling
function App() {
const [phoneNumber, setPhoneNumber] = useState('');
const [otp, setOtp] = useState('');
// Store pinId received from backend (DEMO ONLY - consequence of insecure backend practice, see backend notes)
const [pinId, setPinId] = useState('');
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showOtpInput, setShowOtpInput] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const handleRequestOtp = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setIsVerified(false);
setShowOtpInput(false); // Reset OTP field visibility
setOtp(''); // Clear previous OTP input
setPinId(''); // Clear previous pinId
try {
// Use relative path because of Vite proxy '/api'
const response = await axios.post('/api/otp/request', { phoneNumber });
if (response.data.success) {
setMessage('OTP requested successfully. Check your phone.');
// Store pinId (DEMO ONLY - In production, pinId should NOT be sent to/stored on the client)
setPinId(response.data.pinId);
setShowOtpInput(true); // Show OTP input field
} else {
setMessage(`Error: ${response.data.message}`);
}
} catch (error) {
console.error('Request OTP error:', error);
const errorMsg = error.response?.data?.message || 'Failed to request OTP. Check backend connection and phone number format (E.164).';
setMessage(`Error: ${errorMsg}`);
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setIsVerified(false);
// In production, pinId would NOT come from state, but be handled server-side.
if (!pinId) {
setMessage('Error: Missing OTP session identifier (pinId). Please request OTP again.');
setIsLoading(false);
return;
}
try {
// Use relative path because of Vite proxy '/api'
const response = await axios.post('/api/otp/verify', { pinId, otp });
if (response.data.success) {
setMessage('OTP Verified Successfully!');
setIsVerified(true);
setShowOtpInput(false); // Hide OTP field after success
setPinId(''); // Clear pinId after successful verification
// --- Here you would typically grant access or perform the secured action ---
console.log('Proceed with authenticated action...');
} else {
setMessage(`Error: ${response.data.message}`);
setIsVerified(false);
// Optionally clear OTP input on failure? Or let user retry? Depends on UX choice.
// setOtp('');
}
} catch (error) {
console.error('Verify OTP error:', error);
const errorMsg = error.response?.data?.message || 'Failed to verify OTP. Check the code or request a new one.';
setMessage(`Error: ${errorMsg}`);
setIsVerified(false);
} finally {
setIsLoading(false);
}
};
return (
<div className=""App"">
<h1>Infobip 2FA Demo (React + Node.js)</h1>
{isVerified ? (
<div className=""success-message"">
<h2>Access Granted!</h2>
<p>{message}</p>
<button onClick={() => {
setIsVerified(false);
setMessage('');
setPhoneNumber('');
setOtp('');
setPinId(''); // Clear pinId state
}}>Start Over</button>
</div>
) : (
<>
{!showOtpInput ? (
<form onSubmit={handleRequestOtp}>
<h2>Step 1: Request OTP</h2>
<label htmlFor=""phoneNumber"">Phone Number (E.164 format):</label>
<input
type=""tel""
id=""phoneNumber""
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder=""e.g., 447123456789""
required
disabled={isLoading}
/>
<button type=""submit"" disabled={isLoading || !phoneNumber}>
{isLoading ? 'Sending...' : 'Request OTP'}
</button>
</form>
) : (
<form onSubmit={handleVerifyOtp}>
<h2>Step 2: Verify OTP</h2>
<p>Enter the code sent to {phoneNumber}</p>
<label htmlFor=""otp"">OTP Code:</label>
<input
type=""text"" // Use ""text"" to allow easier input on some devices
inputMode=""numeric"" // Hint for numeric keyboard
pattern=""[0-9]*"" // Basic pattern validation
autoComplete=""one-time-code"" // Helps browsers/password managers suggest the code
id=""otp""
value={otp}
onChange={(e) => setOtp(e.target.value)}
// IMPORTANT: Ensure this maxLength matches the ""PIN Length""
// configured in your Infobip Message Template (Step 3.4).
maxLength=""6""
required
disabled={isLoading}
/>
<button type=""submit"" disabled={isLoading || !otp || otp.length < 6}>
{isLoading ? 'Verifying...' : 'Verify OTP'}
</button>
<button type=""button"" onClick={() => {
setShowOtpInput(false);
setMessage('');
setOtp('');
// Keep phone number for convenience? Or clear? User choice.
// setPhoneNumber('');
}} disabled={isLoading} style={{marginLeft: '10px', background: '#ccc'}}>
Change Number / Resend
</button>
</form>
)}
{message && <p className={`message ${isVerified ? 'success' : message.toLowerCase().includes('error') ? 'error' : 'info'}`}>{message}</p>}
</>
)}
</div>
);
}
export default App;
3. Basic Styling (Optional):
Add some simple styles to client/src/App.css
:
/* client/src/App.css */
.App {
font-family: sans-serif;
max-width: 500px;
margin: 40px auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
text-align: center;
}
form {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 20px;
}
label {
font-weight: bold;
text-align: left;
margin-bottom: -10px; /* Adjust spacing */
}
input[type=""tel""],
input[type=""text""] {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1em;
}
button {
padding: 12px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1em;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:disabled {
background-color: #aaa;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
.message {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
border: 1px solid transparent; /* Base border */
}
.info {
color: #00529B;
background-color: #BDE5F8;
border-color: #00529B;
}
.error {
color: #D8000C;
background-color: #FFD2D2;
border-color: #D8000C;
}
.success {
color: #4F8A10;
background-color: #DFF2BF;
border-color: #4F8A10;
}
.success-message h2 {
color: #4F8A10;
margin-bottom: 10px;
}
.success-message p {
margin-bottom: 15px;
}
- Explanation:
- Uses
useState
to manage component state (phone number, OTP, messages, loading status, UI visibility). handleRequestOtp
sends the phone number to the backend/api/otp/request
endpoint. Crucially, in this demo, it receives and stores thepinId
from the backend, which is insecure. On success, it shows the OTP input field.handleVerifyOtp
sends the (insecurely obtained)pinId
and the entered OTP to the backend/api/otp/verify
endpoint.- Uses
axios
to make the API calls. The URLs (/api/...
) are relative because Vite's proxy handles forwarding them to the backend. - Displays feedback messages (success/error/info) and loading states.
- Conditionally renders the phone number input or the OTP input based on
showOtpInput
. - Shows a success message upon verification.
- Added
inputMode=""numeric""
andautoComplete=""one-time-code""
to the OTP input for better UX. - Added note about matching
maxLength
to Infobip configuration.
- Uses
5. Error Handling and Logging
- Backend (
server.js
):- We included
try...catch
blocks aroundaxios
calls to Infobip. - Logs errors to the console (
console.error
). For production, use a structured logging library (like Winston or Pino) to log to files or external services, including request IDs for tracing. - Attempts to parse specific error messages from Infobip's response (
error.response.data
). Added specific checks for commonmessageId
values likePIN_NOT_FOUND
,WRONG_PIN
,MAX_ATTEMPTS_EXCEEDED
. - Returns consistent JSON error responses (
{ success: false, message: '...' }
) with appropriate HTTP status codes (400, 500, etc.).
- We included
- Frontend (
App.jsx
):try...catch
blocks aroundaxios
calls to the backend API.- Displays user-friendly error messages received from the backend (
error.response?.data?.message
). - Provides generic fallback messages if the backend response is unexpected.
- Uses CSS classes (
info
,error
,success
) for message styling.
- Retry Mechanisms: For transient network issues calling Infobip, you could implement a retry strategy (e.g., exponential backoff) on the backend using libraries like
axios-retry
or manually within thecatch
block. However, retrying OTP verification itself is usually handled by letting the user re-enter the code or request a new one. Infobip handles rate limiting on their end based on your application settings.
6. Security Considerations
- API Key Security: Never commit your
INFOBIP_API_KEY
or other secrets to version control. Use.env
and ensure.env
is in your.gitignore
. In production, use secure environment variable management provided by your hosting platform. - Rate Limiting:
- Infobip: Configure sensible rate limits within your Infobip Application settings (
sendPinPerApplicationLimit
,sendPinPerPhoneNumberLimit
,verifyPinLimit
) to prevent abuse and control costs. - Backend API: Implement rate limiting on your Node.js endpoints (
/api/otp/request
,/api/otp/verify
) using libraries likeexpress-rate-limit
to protect your own server resources from brute-force attempts.
- Infobip: Configure sensible rate limits within your Infobip Application settings (
- Input Validation: Sanitize and validate all user inputs thoroughly on the backend (phone number format, OTP format/length). We added basic phone validation; libraries like
joi
orexpress-validator
offer more robust solutions. - HTTPS: Always use HTTPS for both your frontend and backend in production to encrypt communication.
pinId
Handling (CRITICAL REMINDER): As stressed multiple times, do not exposepinId
to the client in production. This example does so for simplicity only. The correct approach involves storing thepinId
securely on the server-side, linked to the user's session, and retrieving it during verification based on that session.- Brute Force Protection: Infobip's
pinAttempts
setting helps mitigate OTP brute-forcing. Your backend rate limiting adds another layer. - Session Management: Integrate this OTP flow into a proper authentication system with secure session management (e.g., using signed cookies, JWTs with appropriate handling). The OTP verification step should typically occur after initial credential validation or as part of a step-up authentication process.
7. Troubleshooting and Caveats
- Infobip Errors:
401 Unauthorized
: IncorrectINFOBIP_API_KEY
or Base URL. Check.env
and Infobip portal. Ensure theAuthorization
header format isApp YOUR_API_KEY
.400 Bad Request
(on Request OTP): Often invalid phone number format (needs E.164), incorrectapplicationId
ormessageId
, or missing required fields. Check Infobip API docs and your request payload. Also check Infobip rate limits.400 Bad Request
(on Verify OTP):PIN_NOT_FOUND
(invalidpinId
provided by client (due to demo flaw) or expired session),WRONG_PIN
(incorrect OTP entered),MAX_ATTEMPTS_EXCEEDED
(configured limit reached). CheckpinId
handling (server-side in production!), user input, and Infobip settings.403 Forbidden
: Sometimes related to sender ID issues, geographic restrictions, or account permissions. Check Infobip configuration and account status.500 Internal Server Error
: Issue on Infobip's side. Retry might be appropriate depending on the context.
- Configuration Mismatch: Ensure
INFOBIP_APP_ID
andINFOBIP_MSG_ID
in.env
exactly match the IDs created in the Infobip portal. Ensure thePIN Length
configured in the Infobip Message Template matches themaxLength
attribute on the OTP input field in the frontend (App.jsx
). - CORS Errors (Development): If not using the Vite proxy, ensure the
cors
middleware on the backend (server.js
) correctly allows the origin of your frontend (e.g.,http://localhost:5173
). Check the browser's developer console for specific CORS error messages. - Phone Number Formatting: Infobip strictly requires the E.164 format (e.g.,
447123456789
for a UK number,14155552671
for a US number). Ensure users input numbers correctly or implement frontend/backend formatting/validation. - Demo Security Flaw: Remember that the handling of
pinId
in this example (passing it to the client) is insecure and for demonstration purposes only. Implement server-side storage forpinId
in any real application.