Two-factor authentication (2FA) adds a crucial layer of security to user accounts by requiring a second verification step beyond just a password. One common and user-friendly method for 2FA is using One-Time Passwords (OTP) sent via SMS.
This guide provides a complete walkthrough for implementing SMS-based OTP verification in a Node.js application using the Express framework and the Infobip 2FA API. We will build a simple API that can send an OTP to a user's phone number and then verify the code they enter.
Project Goals:
- Create a secure and reliable SMS OTP verification flow.
- Integrate with the Infobip 2FA API for sending and verifying OTPs.
- Build corresponding API endpoints in a Node.js Express application.
- Handle configuration, security, and error scenarios appropriately.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express.js: Minimalist web framework for Node.js.
- Infobip 2FA API: Service for generating, sending, and verifying OTPs via SMS, Voice, or Email. We'll focus on SMS.
- Axios: Promise-based HTTP client for making requests to the Infobip API.
- dotenv: Module for loading environment variables from a
.env
file.
System Architecture:
The basic flow involves our Node.js application interacting with the Infobip API. The Node.js app receives requests from the client, forwards OTP generation requests to Infobip, receives the pinId
back, responds to the client that the OTP is sent, and later receives verification requests from the client to validate against Infobip. Infobip handles sending the SMS to the user after the Node.js app requests it.
sequenceDiagram
participant User
participant ClientApp as Client App (e.g., Web/Mobile)
participant NodeApp as Node.js Express API
participant Infobip
User->>ClientApp: Initiates action requiring OTP (e.g., Login, Profile Update)
ClientApp->>NodeApp: POST /send-otp (with phone number)
NodeApp->>Infobip: POST /2fa/2/pin (Generate & Send OTP Request)
Infobip-->>NodeApp: Response (includes pinId)
NodeApp-->>ClientApp: Success response (OTP sent confirmation)
Infobip->>User: Sends SMS with OTP code (happens after NodeApp request)
User->>ClientApp: Enters OTP code
ClientApp->>NodeApp: POST /verify-otp (with phone number, OTP code)
NodeApp->>Infobip: POST /2fa/2/pin/{pinId}/verify (Verify OTP Request using stored pinId)
Infobip-->>NodeApp: Response (verified: true/false)
NodeApp-->>ClientApp: Verification result (Success/Failure)
ClientApp->>User: Shows appropriate message (Login success, Error, etc.)
Prerequisites:
- Node.js and npm (or yarn): Installed on your system. (Download Node.js)
- Infobip Account: A registered account with Infobip. You can sign up for a free trial. (Infobip Signup)
- Infobip API Key and Base URL: Obtained from your Infobip account dashboard after signup.
- Basic understanding of Node.js, Express, and REST APIs.
Final Outcome:
By the end of this guide, you will have a functional Node.js Express API with two endpoints: /send-otp
and /verify-otp
. This API will leverage Infobip to handle the complexities of OTP generation, delivery via SMS, and verification, ready to be integrated into a larger application's authentication flow.
1. Setting up the project
Let's start by creating our Node.js project and installing the necessary dependencies.
1. Create Project Directory: Open your terminal and create a new directory for the project.
mkdir infobip-otp-guide
cd infobip-otp-guide
2. Initialize Node.js Project:
Initialize the project using npm (or yarn). This creates a package.json
file.
npm init -y
3. Install Dependencies:
Install Express for the web server, dotenv
for environment variable management, and axios
for making HTTP requests to the Infobip API.
npm install express dotenv axios
4. Project Structure: Create the basic files and folders.
touch index.js .env .gitignore
Your initial structure should look like this:
infobip-otp-guide/
├── node_modules/
├── .env
├── .gitignore
├── index.js
└── package.json
5. Configure .gitignore
:
Add node_modules
and .env
to your .gitignore
file to prevent committing sensitive information and dependencies.
node_modules
.env
6. Basic Express Server Setup:
Add the following initial code to index.js
to set up a basic Express server.
// Load environment variables from .env file
require('dotenv').config();
const express = require('express');
const app = express();
// Middleware to parse JSON request bodies
app.use(express.json());
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('Infobip OTP Service Running!');
});
// --- OTP Routes will go here ---
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
You can run this basic server to ensure setup is correct:
node index.js
You should see Server running on port 3000
in your console. You can stop the server with Ctrl+C
.
2. Infobip configuration
Before interacting with the Infobip API, we need to configure it within Infobip and store the necessary credentials securely in our application.
1. Obtain Infobip API Key and Base URL:
- Log in to your Infobip account.
- Navigate to the API Keys section (often found under account settings or developer tools).
- Create a new API key if you don't have one. Copy this key immediately as it might not be shown again.
- Note your Base URL. This is the domain you'll use for API requests (e.g.,
your-account.api.infobip.com
). The specific Base URL is usually displayed near your API keys or in the general API documentation entry point for your account.
2. Store Credentials in .env
:
Open the .env
file and add your Infobip credentials.
# Infobip Credentials
INFOBIP_API_KEY=YOUR_COPIED_API_KEY
INFOBIP_BASE_URL=YOUR_BASE_URL # e.g., xyz123.api.infobip.com
# Infobip 2FA Configuration (We will get these next)
INFOBIP_2FA_APP_ID=
INFOBIP_2FA_MSG_ID=
# Server Port (Optional)
PORT=3000
Replace YOUR_COPIED_API_KEY
and YOUR_BASE_URL
with your actual values. Remember that the .env
file should not be committed to version control.
3. Create Infobip 2FA Application:
An Infobip Application
defines the behavior and rules for your 2FA flow (like PIN attempts, validity time). You need to create one using the Infobip API.
- Open your terminal (or use a tool like Postman).
- Replace placeholders: Substitute
YOUR_BASE_URL
andYOUR_API_KEY
with your actual credentials in the command below. - Important: Note that the Authorization header format requires the literal word
App
, followed by a space, then your API key:-H 'Authorization: App YOUR_API_KEY'
. - Execute the
curl
command:
curl -X POST \
https://YOUR_BASE_URL/2fa/2/applications \
-H 'Authorization: App YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
""name"": ""My Nodejs App OTP"",
""configuration"": {
""pinAttempts"": 10,
""allowMultiplePinVerifications"": true,
""pinTimeToLive"": ""10m"",
""verifyPinLimit"": ""1/3s"",
""sendPinPerApplicationLimit"": ""10000/1d"",
""sendPinPerPhoneNumberLimit"": ""3/1d""
},
""enabled"": true
}'
- Explanation of Configuration:
name
: A descriptive name for this 2FA configuration.pinAttempts
: Maximum number of verification attempts allowed for a single PIN.allowMultiplePinVerifications
: Whether the same PIN can be verified multiple times (useful for testing, potentially disable in production).pinTimeToLive
: How long the generated PIN is valid (e.g., ""10m"" for 10 minutes).verifyPinLimit
: Rate limit for verification attempts per PIN ID.sendPinPerApplicationLimit
: Rate limit for sending PINs across the entire application.sendPinPerPhoneNumberLimit
: Rate limit for sending PINs to a single phone number.enabled
: Whether this configuration is active.
- Capture the
applicationId
: The API response will be a JSON object containing anapplicationId
. Copy this value.
// Example Response
{
""applicationId"": ""ABC123DEF456GHI789JKL012MNO345PQR"", // <-- Copy this value
""name"": ""My Nodejs App OTP""_
""configuration"": {
// ... configuration details ...
}_
""enabled"": true
}
- Update
.env
: Add the copiedapplicationId
to your.env
file:
# ... other variables ...
INFOBIP_2FA_APP_ID=ABC123DEF456GHI789JKL012MNO345PQR
# ... other variables ...
4. Create Infobip Message Template: This template defines the content of the SMS message sent to the user_ including the placeholder for the OTP code.
- Replace placeholders: Substitute
YOUR_BASE_URL
_YOUR_API_KEY
_ andYOUR_2FA_APP_ID
in the command below. - Choose Sender ID: The
senderId
is what appears as the sender on the user's phone. For trial accounts_ this might be restricted. For paid accounts_ you can often register a custom alphanumeric sender ID or use a purchased phone number. Check Infobip documentation for specifics in your region. Replace""InfoSMS""
with your desired/allowed sender ID. - Important: Again_ ensure the Authorization header uses the
App YOUR_API_KEY
format. - Execute the
curl
command:
curl -X POST \
https://YOUR_BASE_URL/2fa/2/applications/YOUR_2FA_APP_ID/messages \
-H 'Authorization: App YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
""messageText"": ""Your verification code is {{pin}}. It expires in 10 minutes.""_
""pinLength"": 6_
""pinType"": ""NUMERIC""_
""senderId"": ""InfoSMS""_
""language"": ""en""
}'
- Explanation:
messageText
: The SMS body.{{pin}}
is the mandatory placeholder for the OTP.pinLength
: The number of digits for the OTP (e.g._ 6).pinType
: The type of PIN (NUMERIC
_ALPHA
_HEX
_ALPHANUMERIC
).senderId
: The sender ID displayed to the user.language
: Helps with potential future localization or specific character encoding needs.
- Capture the
messageId
: The API response will contain amessageId
. Copy this value.
// Example Response
{
""messageId"": ""XYZ987WVU654TSR321QPO098NML765KJI""_ // <-- Copy this value
""messageText"": ""Your verification code is {{pin}}. It expires in 10 minutes.""_
// ... other template details ...
}
- Update
.env
: Add the copiedmessageId
to your.env
file:
# ... other variables ...
INFOBIP_2FA_MSG_ID=XYZ987WVU654TSR321QPO098NML765KJI
# ... other variables ...
Now your application has the necessary credentials and configuration IDs stored securely.
3. Implementing core functionality: Sending and Verifying OTPs
Let's write the functions that interact with the Infobip API. We'll create helper functions for sending and verifying OTPs.
1. Setup Axios Instance:
It's good practice to create a pre-configured Axios instance for interacting with the Infobip API. Add this near the top of index.js
:
// ... require statements ...
const axios = require('axios'); // Ensure axios is required
const app = express();
app.use(express.json());
// Configure Axios instance for Infobip API
const infobipAxios = axios.create({
baseURL: `https://${process.env.INFOBIP_BASE_URL}`_ // Use HTTPS
headers: {
'Authorization': `App ${process.env.INFOBIP_API_KEY}`_ // Note the 'App ' prefix
'Content-Type': 'application/json'_
'Accept': 'application/json'_
}
});
// --- In-memory storage for PIN IDs (Replace with DB/Redis in production) ---
> **WARNING: PRODUCTION UNSUITABLE - DO NOT USE IN PRODUCTION**
>
> The `activePinIds` object stores PIN IDs in server memory.
> This approach WILL NOT WORK correctly in a production environment:
> 1. Data Loss: All active PIN IDs are lost if the server restarts.
> 2. Scalability Issues: Cannot be scaled horizontally (multiple instances)
> as each instance would have its own separate memory store.
>
> REPLACE this with a persistent, shared storage solution like Redis
> (recommended for TTL support) or a database before deploying.
> See Section 6 for details.
const activePinIds = {}; // Stores mapping: phoneNumber -> pinId
// ... rest of the server setup ...
2. Send OTP Function:
This function takes a phone number, calls the Infobip API to send the PIN, and stores the returned pinId
associated with the phone number (temporarily in memory for this example).
// ... infobipAxios setup ...
// ... activePinIds setup ...
/**
* Sends an OTP via Infobip to the specified phone number.
* @param {string} phoneNumber - The recipient's phone number in E.164 format (e.g., 447... or 1415...).
* @returns {Promise<object>} - Object containing pinId and recipient number.
* @throws {Error} - If the API call fails or configuration is missing.
*/
async function sendOtp(phoneNumber) {
const appId = process.env.INFOBIP_2FA_APP_ID;
const msgId = process.env.INFOBIP_2FA_MSG_ID;
if (!appId || !msgId) {
throw new Error("Infobip App ID or Message ID is missing in configuration.");
}
if (!phoneNumber) {
throw new Error("Phone number is required.");
}
console.log(`Sending OTP to: ${phoneNumber}`);
try {
const response = await infobipAxios.post('/2fa/2/pin', {
applicationId: appId,
messageId: msgId,
to: phoneNumber,
// 'from' can be specified here if needed, otherwise uses template's default
});
const pinId = response.data.pinId;
console.log(`OTP sent successfully. Pin ID: ${pinId}`);
// Store the pinId associated with the phone number for verification
// WARNING: Using IN-MEMORY storage - REPLACE for production (see Section 6)
activePinIds[phoneNumber] = pinId;
// Optionally, schedule removal of pinId after expiry (e.g., 10 mins)
// This helps clean up memory but doesn't solve the core persistence/scaling issue.
setTimeout(() => {
if (activePinIds[phoneNumber] === pinId) { // Only delete if it hasn't been replaced by a newer OTP request
delete activePinIds[phoneNumber];
console.log(`Expired pinId ${pinId} removed for ${phoneNumber}`);
}
}, 10 * 60 * 1000 + 5000); // 10 minutes (matching pinTimeToLive) + 5 seconds buffer
return { pinId: pinId, to: response.data.to };
} catch (error) {
console.error("Error sending OTP:", error.response ? error.response.data : error.message);
// Extract more specific error info if available
const errorData = error.response?.data?.requestError?.serviceException;
const errorMessage = errorData ? `${errorData.messageId}: ${errorData.text}` : 'Failed to send OTP.';
throw new Error(errorMessage);
}
}
// ... rest of the server setup ...
- Key points:
- Uses the configured
applicationId
andmessageId
. - Takes the phone number (
to
) as input. Ensure it's in the correct format (usually E.164, e.g.,+14155552671
or442071838750
). Infobip is generally flexible but E.164 is standard. - Makes a
POST
request to/2fa/2/pin
. - Extracts the
pinId
from the successful response. ThispinId
is essential for the verification step. - Stores the
pinId
in our simpleactivePinIds
object, mapping the phone number to its latestpinId
. This is the part needing replacement for production. - Includes basic error handling and logging.
- Uses the configured
3. Verify OTP Function:
This function takes the phone number, the user-submitted PIN, retrieves the corresponding pinId
from the temporary store, and calls the Infobip API to verify the PIN.
// ... sendOtp function ...
/**
* Verifies an OTP using the pinId associated with the phone number.
* @param {string} phoneNumber - The user's phone number used to retrieve the pinId.
* @param {string} pin - The OTP code entered by the user.
* @returns {Promise<boolean>} - True if verification is successful, false otherwise.
* @throws {Error} - If the API call fails or configuration is missing.
*/
async function verifyOtp(phoneNumber, pin) {
// Retrieve pinId from temporary storage - REPLACE for production
const pinId = activePinIds[phoneNumber];
if (!pinId) {
console.warn(`No active PIN ID found for phone number: ${phoneNumber}. It may have expired or was never sent.`);
return false; // Treat as verification failure
}
if (!pin) {
throw new Error("PIN code is required for verification.");
}
console.log(`Verifying OTP for Pin ID: ${pinId}`);
try {
const response = await infobipAxios.post(`/2fa/2/pin/${pinId}/verify`, {
pin: pin,
});
const isVerified = response.data.verified;
console.log(`Verification result for ${pinId}: ${isVerified}`);
if (isVerified) {
// Verification successful, remove the pinId from active storage
delete activePinIds[phoneNumber];
} else {
// Optional: Handle failed attempts logic here if needed (e.g., increment counter)
// If pinAttempts limit is reached on Infobip side, subsequent verify calls will fail anyway.
console.log(`Incorrect PIN entered for ${pinId}`);
}
return isVerified;
} catch (error) {
console.error(`Error verifying OTP for Pin ID ${pinId}:`, error.response ? error.response.data : error.message);
// Check for specific errors from Infobip if possible
const errorData = error.response?.data?.requestError?.serviceException;
if (errorData?.messageId === 'TOO_MANY_ATTEMPTS') {
console.warn(`Verification blocked for ${pinId} due to too many attempts.`);
delete activePinIds[phoneNumber]; // Invalidate the pinId locally
return false; // Treat as verification failure
}
if (error.response?.status === 404) { // Pin ID not found / expired on Infobip's side
console.warn(`Pin ID ${pinId} not found or expired.`);
delete activePinIds[phoneNumber]; // Clean up local state
return false; // Treat as verification failure
}
// Rethrow for other unexpected errors
const errorMessage = errorData ? `${errorData.messageId}: ${errorData.text}` : 'Failed to verify OTP.';
throw new Error(errorMessage);
}
}
// ... rest of the server setup ...
- Key points:
- Retrieves the
pinId
from ouractivePinIds
store using the phone number. Handles the case where nopinId
is found (e.g., expired or never sent). - Makes a
POST
request to/2fa/2/pin/{pinId}/verify
, including thepinId
in the URL and the user's submittedpin
in the body. - Checks the
verified
property in the response (true/false). - Removes the
pinId
from storage upon successful verification to prevent reuse. This relies on the temporary store. - Includes error handling, specifically logging warnings for incorrect PINs and handling common Infobip errors like
TOO_MANY_ATTEMPTS
or404 Not Found
(which usually indicates an expired/invalidpinId
).
- Retrieves the
4. Building the API Layer
Now, let's expose the sendOtp
and verifyOtp
functions through Express API endpoints.
1. Define API Routes:
Add the following route handlers in index.js
before the app.listen
call.
// ... verifyOtp function ...
// === API Endpoints ===
app.post('/send-otp', async (req, res) => {
const { phoneNumber } = req.body;
if (!phoneNumber) {
return res.status(400).json({ error: 'Phone number is required.' });
}
// Basic validation (very simple, consider using a library like libphonenumber-js for robust validation)
if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' });
}
try {
const result = await sendOtp(phoneNumber);
// SECURITY CRITICAL: Do NOT send the pinId back to the client.
// The pinId is a server-side identifier used to link the verification attempt
// back to the original send request. Exposing it could potentially lead to
// information leakage or misuse if not handled carefully on the client-side
// (which is unnecessary complexity). The server manages the pinId internally.
// The client only needs confirmation that the OTP was requested successfully.
res.status(200).json({ message: 'OTP sent successfully.' /* , pinId: result.pinId */ }); // Ensure pinId is NOT returned
} catch (error) {
// Log the detailed error on the server, but send a generic error to the client.
console.error(""Send OTP endpoint error:"", error);
res.status(500).json({ error: error.message || 'Failed to send OTP.' });
}
});
app.post('/verify-otp', async (req, res) => {
const { phoneNumber, pin } = req.body;
if (!phoneNumber || !pin) {
return res.status(400).json({ error: 'Phone number and PIN code are required.' });
}
// Basic validation
if (!/^\d{6}$/.test(pin)) { // Assuming 6-digit numeric PIN based on template
return res.status(400).json({ error: 'Invalid PIN format. Expecting 6 digits.' });
}
if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format.' });
}
try {
// This function uses the temporary 'activePinIds' store. Needs replacement for production.
const isVerified = await verifyOtp(phoneNumber, pin);
if (isVerified) {
res.status(200).json({ message: 'OTP verified successfully.' });
} else {
// Use 401 Unauthorized or 400 Bad Request for failed verification attempts
// (due to incorrect PIN, expired PIN, or no active attempt found).
res.status(401).json({ error: 'Invalid or expired OTP.' });
}
} catch (error) {
// Log the detailed server error but send a generic message to the client.
console.error(""Verification endpoint error:"", error);
res.status(500).json({ error: 'Failed to verify OTP.' });
}
});
// ... app.listen ...
- Key points:
/send-otp
(POST): ExpectsphoneNumber
in the JSON body. CallssendOtp
. Returns a success message. Explicitly states the security reason for not returning thepinId
to the client. Basic phone number format validation added./verify-otp
(POST): ExpectsphoneNumber
and thepin
code in the JSON body. CallsverifyOtp
(which currently depends on the temporaryactivePinIds
store). Returns success (200) or failure (401) based on the result. Basic PIN and phone number validation added.- Includes basic input validation for required fields and formats.
- Handles errors from the service functions and returns appropriate HTTP status codes (400 for bad client input, 500 for server errors, 401 for failed verification). Server logs detailed errors, client receives generic messages.
2. Testing with curl
or Postman:
-
Start the server:
node index.js
-
Send OTP: (Replace
+1XXXXXXXXXX
with a valid phone number, preferably one associated with your Infobip trial account if applicable).curl -X POST http://localhost:3000/send-otp \ -H ""Content-Type: application/json"" \ -d '{ ""phoneNumber"": ""+1XXXXXXXXXX"" }'
Expected Response (200 OK):
{ ""message"": ""OTP sent successfully."" }
You should receive an SMS on the target phone number.
-
Verify OTP: (Replace
+1XXXXXXXXXX
with the same phone number and123456
with the actual code you received).curl -X POST http://localhost:3000/verify-otp \ -H ""Content-Type: application/json"" \ -d '{ ""phoneNumber"": ""+1XXXXXXXXXX"", ""pin"": ""123456"" }'
Expected Response (Success - 200 OK):
{ ""message"": ""OTP verified successfully."" }
Expected Response (Incorrect PIN - 401 Unauthorized):
{ ""error"": ""Invalid or expired OTP."" }
5. Error handling, logging, and retry mechanisms
We've added basic try...catch
blocks and logging. Let's refine this.
- Consistent Error Handling: Our current approach returns 400 for client errors, 401 for failed auth, and 500 for server/API errors, which is a good start. Ensure error messages logged on the server are detailed, but client-facing messages are less specific for security (e.g., "Invalid or expired OTP" instead of "PIN expired").
- Logging: We are using
console.log
andconsole.error
. For production, use a dedicated logging library (like Winston or Pino) to:- Write logs to files or external services.
- Set different log levels (debug, info, warn, error).
- Standardize log formats (e.g., JSON) for easier parsing.
- Retry Mechanisms: Network issues can occur when calling Infobip.
- Idempotency: Sending an OTP is not typically idempotent (sending twice generates two different PINs). Verification might be, depending on Infobip's implementation (verifying the same correct PIN twice might succeed both times if
allowMultiplePinVerifications
is true, but it uses up an attempt). - Strategy: Retries are generally not recommended for the
/send-otp
endpoint from the server-side automatically, as it could lead to multiple unwanted SMS messages and costs. If a send request fails, it's usually better to return an error to the client and let the user retry the action. For/verify-otp
, a limited retry (e.g., 1 retry on a 5xx error or timeout from Infobip) might be acceptable, but be cautious. Implement retries with exponential backoff (wait longer between each retry) using libraries likeaxios-retry
if deemed necessary.
- Idempotency: Sending an OTP is not typically idempotent (sending twice generates two different PINs). Verification might be, depending on Infobip's implementation (verifying the same correct PIN twice might succeed both times if
Example (Conceptual Logging Enhancement):
// Replace console.log/error with a proper logger instance
// const logger = require('./logger'); // Assuming logger setup elsewhere
// In sendOtp error handler:
// logger.error({ message: "Error sending OTP", phoneNumber: phoneNumber, errorDetails: error.response?.data || error.message, stack: error.stack });
// throw new Error('Failed to send OTP.'); // Generic message for client
// In verifyOtp error handler:
// logger.error({ message: "Error verifying OTP", pinId: pinId, errorDetails: error.response?.data || error.message, stack: error.stack });
// throw new Error('Failed to verify OTP.'); // Generic message for client
6. Database schema and data layer (The Production Necessity)
Our current implementation uses an in-memory object (activePinIds
) to store the mapping between phone numbers and active pinId
s. This is explicitly unsuitable for production for the reasons highlighted in the warning block in Section 3 (data loss on restart, inability to scale).
Production Approach: Replacing In-Memory Storage
You must replace the activePinIds
object with a persistent and potentially shared data store before deploying. Common choices include:
- Redis: An in-memory data store often used for caching and temporary data. Highly recommended for storing OTP
pinId
s because it has built-in support for Time-To-Live (TTL). You can set the TTL to match thepinTimeToLive
configured in Infobip (e.g., 10 minutes), and Redis will automatically expire the entry.- Schema: Key:
otp:phoneNumber:+1XXXXXXXXXX
, Value:pinId:YYYYYYYYYY
, TTL: 600 seconds (for 10 min expiry). - Benefits: Fast lookups, automatic expiry management, scalable.
- Schema: Key:
- Relational Database (PostgreSQL, MySQL, etc.): You could add columns to your
users
table or create a separateotp_attempts
table. This requires manual cleanup of expired entries (e.g., via a scheduled job).- Schema (
users
table enhancement):otp_pin_id
(VARCHAR, nullable): Stores the latest active pinId.otp_pin_expires_at
(TIMESTAMP, nullable): Stores when the pinId expires. (Requires logic to check expiry).
- Schema (
otp_attempts
table):id
(PK)user_id
(FK to users, optional)phone_number
(VARCHAR, indexed)pin_id
(VARCHAR, unique)expires_at
(TIMESTAMP, indexed)verified_at
(TIMESTAMP, nullable)created_at
(TIMESTAMP) (Requires cleanup job).
- Schema (
Implementation Sketch (using Redis with ioredis
):
// Requires 'ioredis' client: npm install ioredis
// const Redis = require('ioredis');
// const redisClient = new Redis({ /* connection options, e.g., process.env.REDIS_URL */ });
// In sendOtp function (replace activePinIds[phoneNumber] = pinId):
// const pinTTLSeconds = 10 * 60; // Match pinTimeToLive (10 minutes)
// await redisClient.set(`otp:phoneNumber:${phoneNumber}`, pinId, 'EX', pinTTLSeconds);
// console.log(`Stored pinId ${pinId} in Redis for ${phoneNumber} with TTL ${pinTTLSeconds}s`);
// In verifyOtp function (replace const pinId = activePinIds[phoneNumber]):
// const pinId = await redisClient.get(`otp:phoneNumber:${phoneNumber}`);
// if (!pinId) {
// console.warn(`No active PIN ID found in Redis for phone number: ${phoneNumber}`);
// return false; // Not found or expired
// }
// ... make verification call to Infobip ...
// if (isVerified) {
// // Verification successful, remove the pinId from Redis
// await redisClient.del(`otp:phoneNumber:${phoneNumber}`);
// } else if (errorData?.messageId === 'TOO_MANY_ATTEMPTS' || error.response?.status === 404) {
// // Also remove from Redis if Infobip says it's invalid/expired/too many attempts
// await redisClient.del(`otp:phoneNumber:${phoneNumber}`);
// }
Choose the approach that best fits your application's architecture and scale. Redis with TTL is generally the preferred method for this use case due to its performance and built-in expiration handling, simplifying the application logic.
7. Adding security features
Security is paramount for authentication flows.
- Rate Limiting: Prevent brute-force attacks on both sending and verification. Infobip has built-in limits (
verifyPinLimit
,sendPinPerPhoneNumberLimit
), but you should also implement limits in your API layer using middleware likeexpress-rate-limit
. Apply stricter limits to/verify-otp
than/send-otp
.// npm install express-rate-limit const rateLimit = require('express-rate-limit'); const sendOtpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP to 5 OTP send requests per windowMs message: 'Too many OTP requests from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); const verifyOtpLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 10, // Limit each IP to 10 verify attempts per windowMs (adjust based on pinAttempts) message: 'Too many verification attempts from this IP, please try again after 5 minutes', standardHeaders: true, legacyHeaders: false, }); // Apply to specific routes *before* the route handlers app.use('/send-otp', sendOtpLimiter); app.use('/verify-otp', verifyOtpLimiter);
- Input Validation: We added basic checks. For production, use robust libraries:
express-validator
: For validating request bodies, params, and queries structure and types.libphonenumber-js
: For parsing, validating, and normalizing phone numbers thoroughly across different regions.
- Secure Credential Storage:
.env
is good for local development. In production, use secrets management systems provided by your cloud provider (AWS Secrets Manager, Google Secret Manager, Azure Key Vault) or tools like HashiCorp Vault. Never commit API keys or other secrets to Git.