code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / infobip

Infobip 2FA Integration: Complete React + Node.js OTP Tutorial

Learn how to implement Two-Factor Authentication with Infobip OTP service. Step-by-step guide for building secure 2FA with React, Vite, and Node.js backend.

Infobip 2FA Integration: Complete React + Node.js OTP Tutorial

Meta Description: Learn how to implement Two-Factor Authentication with Infobip OTP service. Step-by-step guide for building secure 2FA with React, Vite, and Node.js backend.

Enhance your application's security by adding a robust Two-Factor Authentication (2FA) layer using Infobip's OTP service. This guide walks you through integrating Infobip 2FA into a system with a Vite (React) frontend and a Node.js backend.

You'll build an application where users 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 for implementing secure two-factor authentication.

Project Overview and Goals

  • Problem Solved: Secure user accounts and critical actions by verifying phone number possession through OTP, mitigating risks from 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:

    text
         +-----------------+      +---------------------+      +-----------------+
         | 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 that allows 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 Your 2FA Project Structure

Create a monorepo structure with separate directories for the client (Vite/React) and server (Node.js).

1. Create Project Directory:

bash
mkdir infobip-2fa-app
cd infobip-2fa-app

2. Set Up Backend (Node.js/Express):

bash
# 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: Makes HTTP requests to the Infobip API.

Project Structure (Server):

text
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):

bash
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):

text
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 from your Infobip account.

dotenv
# 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: Store sensitive credentials and configuration outside your codebase for security and maintainability. dotenv makes these accessible via process.env.

2. Building the Node.js Backend API for OTP

Create two endpoints in your Express server: one to request an OTP and another to verify it.

Edit server/server.js:

javascript
// 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:
    • Uses axios for Infobip API calls due to its simplicity and promise-based nature.
    • Implements basic phone number validation (E.164 recommended). Robust validation is crucial in production.
    • Includes essential headers (Authorization, Content-Type, Accept) as required by Infobip.
    • Adds basic error handling to catch issues during API calls and return meaningful responses to the client.
    • CRITICAL SECURITY WARNING: The /api/otp/request endpoint in this demonstration returns the pinId directly to the client. Never do this in production. Store the pinId securely on the server (e.g., associated with the user's session) and never expose it client-side. This example prioritizes simplicity for demonstration over production security practices regarding pinId handling. See the code comments for details.

3. Integrating with Infobip 2FA Service

Configure Infobip and retrieve the necessary credentials for the 2FA process.

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. Find 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 into server/.env for INFOBIP_BASE_URL and INFOBIP_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 remains 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 provides an Application ID (applicationId). * Update .env: Paste this ID into server/.env for INFOBIP_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. Include the {{pin}} placeholder where Infobip inserts 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). A generic one like "Infobip" may work for testing. * Action: Save the message template. Infobip provides a Message Template ID (messageId). * Update .env: Paste this ID into server/.env for INFOBIP_MSG_ID.

Your server/.env file now has all the necessary values.

4. Implementing the React Frontend with Vite

Create a React component to interact with your backend API.

1. Configure Vite Proxy (Optional but Recommended for Development):

Proxy API requests through Vite's dev server to avoid CORS issues during development without complex server configuration.

Edit client/vite.config.js:

javascript
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? Browsers normally restrict 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 API requests come from the same origin (http://localhost:5173).

2. Create the Authentication Component:

Replace the contents of client/src/App.jsx with the following:

jsx
// 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:

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 the pinId 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"" and autoComplete=""one-time-code"" to the OTP input for better UX.
    • Added note about matching maxLength to Infobip configuration.

5. Error Handling and Logging for Production

  • Backend (server.js):
    • We included try...catch blocks around axios 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 common messageId values like PIN_NOT_FOUND, WRONG_PIN, MAX_ATTEMPTS_EXCEEDED.
    • Returns consistent JSON error responses ({ success: false, message: '...' }) with appropriate HTTP status codes (400, 500, etc.).
  • Frontend (App.jsx):
    • try...catch blocks around axios 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 the catch 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 Best Practices for 2FA Implementation

  • 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 like express-rate-limit to protect your own server resources from brute-force attempts.
  • 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 or express-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 expose pinId to the client in production. This example does so for simplicity only. The correct approach involves storing the pinId 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 Common Infobip 2FA Issues

  • Infobip Errors:
    • 401 Unauthorized: Incorrect INFOBIP_API_KEY or Base URL. Check .env and Infobip portal. Ensure the Authorization header format is App YOUR_API_KEY.
    • 400 Bad Request (on Request OTP): Often invalid phone number format (needs E.164), incorrect applicationId or messageId, 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 (invalid pinId provided by client (due to demo flaw) or expired session), WRONG_PIN (incorrect OTP entered), MAX_ATTEMPTS_EXCEEDED (configured limit reached). Check pinId 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 and INFOBIP_MSG_ID in .env exactly match the IDs created in the Infobip portal. Ensure the PIN Length configured in the Infobip Message Template matches the maxLength 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 for pinId in any real application.

Frequently Asked Questions (FAQ)

Q: What is Infobip 2FA and how does it work?

A: Infobip 2FA (Two-Factor Authentication) is a security service that verifies user identity by sending One-Time Passwords (OTPs) via SMS. Users receive a temporary code on their phone, which they enter to confirm their identity. This adds an extra security layer beyond passwords.

Q: How do I get Infobip API credentials for 2FA?

A: Log in to your Infobip account, navigate to API Keys section to generate an API Key and find your Base URL. Then create a 2FA Application and Message Template in the Infobip portal to obtain your Application ID and Message Template ID.

Q: What phone number format does Infobip require?

A: Infobip requires E.164 format – digits only without +, spaces, or hyphens. Examples: 447123456789 (UK), 14155552671 (US). Implement validation on both frontend and backend to ensure correct formatting.

Q: Why is my OTP code not being delivered?

A: Check these common issues: incorrect phone number format (must be E.164), wrong Infobip credentials in .env, exceeded rate limits in your Infobip Application settings, or sender ID restrictions in the destination country. Verify your Infobip account has sufficient credits.

Q: How long should OTP codes remain valid?

A: Set the PIN Time To Live (TTL) to 5-10 minutes in your Infobip Application settings. Shorter durations (5 minutes) provide better security, while longer durations (10 minutes) improve user experience. Balance security with usability for your use case.

Q: Should I store the pinId on the client side?

A: Never store pinId on the client in production. This demo does so for simplicity only. Always store pinId securely on the server, linked to the user's session, and retrieve it during verification. Exposing pinId to clients creates significant security vulnerabilities.

Q: How do I prevent OTP brute-force attacks?

A: Implement multiple layers: configure pinAttempts (e.g., 5 max attempts) in your Infobip Application, add rate limiting on your backend endpoints using express-rate-limit, set rate limits per phone number in Infobip settings, and implement exponential backoff for failed attempts.

Q: Can I use Infobip 2FA with Vue.js instead of React?

A: Yes. The backend Node.js/Express code remains identical. Replace the React frontend (Section 4) with Vue.js components using the same API calls via Axios. The OTP request and verification logic stays the same regardless of frontend framework.

Q: What HTTP status codes does Infobip return?

A: Common codes: 200 (success), 400 (bad request – invalid phone format, wrong OTP, max attempts exceeded), 401 (unauthorized – invalid API key), 403 (forbidden – sender ID or geographic restrictions), 500 (Infobip server error).

Q: How do I test Infobip 2FA without incurring SMS costs?

A: Infobip offers free trial accounts with limited credits. Test with a verified phone number (usually the one used during registration). For extensive testing, consider using Infobip's test mode or sandbox environment if available for your account tier.

Q: What are the best practices for production 2FA deployment?

A: Use HTTPS for all communications, store API keys in secure environment variables (never in code), implement server-side pinId storage with session management, add rate limiting on all endpoints, validate all inputs thoroughly, use structured logging for debugging, and integrate 2FA into your existing authentication system with proper session handling.