Implement robust OTP/2FA in Node.js with Express and AWS SNS
One-Time Passwords (OTPs) sent via SMS are a common and effective way to add a second factor of authentication (2FA) or perform phone number verification in applications. This guide provides a complete walkthrough for building a secure and reliable OTP system using Node.js, the Express framework, and Amazon Simple Notification Service (AWS SNS) for SMS delivery.
We will build a simple REST API with two endpoints: one to request an OTP sent to a provided phone number and another to verify the submitted OTP. This guide covers everything from initial AWS setup and project configuration to implementing core logic, security best practices, error handling, and deployment considerations. By the end, you'll have a functional OTP service ready for integration into your applications.
Project overview and goals
Goal: To create a backend service using Node.js and Express that can:
- Generate a secure OTP.
- Send the OTP via SMS to a user's phone number using AWS SNS.
- Store the OTP temporarily with an expiry time and attempt limits.
- Verify an OTP submitted by the user against the stored value.
Problem Solved: This addresses the need for phone number verification or adding a second authentication factor (2FA) to enhance application security, using widely adopted and scalable cloud services.
Technologies:
- Node.js: A JavaScript runtime environment for building server-side applications.
- Express: A minimal and flexible Node.js web application framework.
- AWS SNS (Simple Notification Service): A fully managed messaging service used here to send SMS messages reliably.
- AWS SDK for JavaScript v3 (
@aws-sdk/client-sns
): To interact with AWS services programmatically using the recommended modular client. - dotenv: To manage environment variables securely.
- (Optional but Recommended): A persistent data store like Redis or DynamoDB for OTP storage in production. This guide uses an in-memory store for simplicity.
Architecture:
graph LR
Client[Client Application] -- POST /request-otp --> API{Node.js/Express API};
Client -- POST /verify-otp --> API;
API -- Generate/Store OTP --> Store[(In-Memory Store / Redis)];
API -- Send SMS --> SNS[AWS SNS];
SNS -- Delivers SMS --> UserPhone[User's Phone];
API -- Verify OTP --> Store;
Prerequisites:
- An AWS account. Sign up here if you don't have one.
- Node.js and npm (or yarn) installed. Download Node.js here.
- Basic understanding of Node.js, Express, and REST APIs.
- Access to a phone number that can receive SMS for testing.
Setting up the project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir node-sns-otp-api cd node-sns-otp-api
-
Initialize Node.js Project: This creates a
package.json
file.npm init -y
-
Install Dependencies: We need Express for the server, the modular AWS SDK v3 client for SNS, and dotenv for environment variables.
npm install express @aws-sdk/client-sns dotenv
express
: Web framework.@aws-sdk/client-sns
: AWS SDK v3 modular client for SNS. This is the recommended way to use the AWS SDK in Node.js.dotenv
: Loads environment variables from a.env
file.
-
Create Project Structure: Organize the project for clarity.
mkdir src mkdir src/routes mkdir src/controllers mkdir src/services touch src/app.js touch src/server.js touch src/routes/otpRoutes.js touch src/controllers/otpController.js touch src/services/otpService.js touch src/services/snsService.js touch src/config.js touch .env touch .gitignore
src/app.js
: Express application setup (middleware, routes).src/server.js
: Starts the HTTP server.src/routes/
: Defines API endpoints.src/controllers/
: Handles request logic and interacts with services.src/services/
: Contains business logic (OTP generation/verification, SNS interaction).src/config.js
: Loads and exports configuration from environment variables..env
: Stores environment variables (AWS keys, configuration). Do not commit this file..gitignore
: Specifies files/directories Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to prevent committing them.node_modules/ .env *.log
-
Configure
.env
: Create a.env
file in your project root.Important Security Note: The values below are placeholders. You must replace
YOUR_AWS_ACCESS_KEY_ID
,YOUR_AWS_SECRET_ACCESS_KEY
, andYOUR_AWS_REGION
with your actual AWS credentials and chosen region. Never commit your.env
file containing real secrets to version control (like Git). Use the.gitignore
file to prevent this. For production environments, use more secure methods like AWS Secrets Manager or environment variables provided by your hosting platform.# AWS Credentials - REPLACE WITH YOUR ACTUAL CREDENTIALS AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY AWS_REGION=YOUR_AWS_REGION # e.g., us-east-1 # Application Configuration PORT=3000 OTP_LENGTH=6 OTP_EXPIRY_MINUTES=5 OTP_MAX_ATTEMPTS=3 # AWS SNS Configuration SNS_SMS_TYPE=Transactional # Or Promotional
AWS_REGION
: The AWS region where you'll operate SNS (choose one that supports SMS, likeus-east-1
,eu-west-1
,ap-southeast-2
). See AWS Regions and Endpoints documentation for details.SNS_SMS_TYPE
: Set toTransactional
for higher delivery priority, especially for OTPs, even to numbers on Do Not Disturb (DND) lists (may incur slightly higher costs).Promotional
is for marketing messages.
AWS setup for SNS
To send SMS messages, we need to configure AWS credentials and SNS settings.
-
Create an IAM User: It's best practice to create a dedicated IAM user with specific permissions rather than using root account keys.
- Navigate to the IAM console in your AWS account.
- Go to Users and click Add users.
- Enter a User name (e.g.,
sns-otp-service-user
). - Select Provide user access to the AWS Management Console (optional).
- Select I want to create an IAM user if creating a console user. Set a password.
- Click Next.
- Select Attach policies directly.
- Search for and select the
AmazonSNSFullAccess
policy. Note: For production, create a more restrictive custom policy granting onlysns:Publish
permissions. - Click Next. Review the settings and click Create user.
- Crucial: On the success screen, navigate to the user you just created. Go to the Security credentials tab. Under Access keys, click Create access key.
- Select Application running outside AWS as the use case.
- Click Next, add an optional description tag, and click Create access key.
- Important: Copy the Access key ID and Secret access key immediately and securely. You won't be able to see the secret key again.
-
Update
.env
file: Paste the copied credentials and your chosen region into your.env
file:AWS_ACCESS_KEY_ID=COPIED_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=COPIED_SECRET_ACCESS_KEY AWS_REGION=us-east-1 # Replace with your chosen region # ... other variables
-
Configure AWS SNS Settings (AWS Console):
- Navigate to the Simple Notification Service (SNS) console in your chosen AWS region.
- Sandbox Limitations (Important): By default, your AWS account is in the SNS sandbox. This means:
- You can only send SMS messages to verified phone numbers.
- There's a low spending limit (e.g., $1.00 USD per month).
- Verify a Phone Number (for Sandbox Testing):
- In the SNS console left navigation pane, click Mobile > Text messaging (SMS).
- Scroll down to the Sandbox destination phone numbers section and click Add phone number.
- Enter the phone number you want to test with (including the country code in E.164 format, e.g.,
+12223334444
). - Select the language for the verification message and click Add phone number.
- You'll receive an SMS with a verification code. Enter it in the AWS console to verify the number.
- Moving out of the Sandbox (for Production):
- To send SMS to any number, you must request to move your account out of the SNS sandbox.
- In the SNS console, under Mobile > Text messaging (SMS), you should see a banner about the sandbox. Click the link to request a limit increase or production access.
- Alternatively, open a support case with AWS requesting ""SNS Text Messaging Spending Limit Increase"" and production access. Explain your use case (e.g., sending OTPs for application security). This process usually takes about 24 hours.
- Set Default SMS Type:
- Still under Mobile > Text messaging (SMS), click Edit in the Account spend limits and default message settings section.
- Under Default message settings, set the Default SMS message type to
Transactional
(recommended for OTPs) orPromotional
based on your.env
setting and use case. - Click Save changes.
Implementing core functionality
Now let's write the code for OTP generation, storage, verification, and sending SMS via SNS.
Configuration Loader
// src/config.js
require('dotenv').config();
const config = {
PORT: parseInt(process.env.PORT, 10) || 3000,
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION,
OTP_LENGTH: parseInt(process.env.OTP_LENGTH, 10) || 6,
OTP_EXPIRY_MINUTES: parseInt(process.env.OTP_EXPIRY_MINUTES, 10) || 5,
OTP_MAX_ATTEMPTS: parseInt(process.env.OTP_MAX_ATTEMPTS, 10) || 3,
SNS_SMS_TYPE: process.env.SNS_SMS_TYPE || 'Transactional', // Default to Transactional
};
// Basic validation
const requiredKeys = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'];
requiredKeys.forEach(key => {
if (!config[key]) {
console.error(`FATAL ERROR: Environment variable ${key} is not defined.`);
process.exit(1); // Exit if critical config is missing
}
});
module.exports = config;
OTP Storage (In-Memory)
For this guide, we'll use a simple JavaScript Map
as an in-memory store. Remember: This is not suitable for production as data is lost on server restart. Use Redis, DynamoDB, or a similar persistent store in a real application. The basic setTimeout
cleanup used here is also rudimentary and may not be perfectly reliable or efficient at scale.
// src/services/otpService.js
const crypto = require('crypto');
const config = require('../config');
// In-memory store (Replace with Redis/DB for production)
const otpStore = new Map(); // Using Map for better performance than object for frequent adds/deletes
function generateOtp() {
const otp = crypto.randomInt(0, Math.pow(10, config.OTP_LENGTH)).toString();
return otp.padStart(config.OTP_LENGTH, '0'); // Ensure fixed length based on config
}
function storeOtp(phoneNumber, otp) {
const expiryTime = Date.now() + config.OTP_EXPIRY_MINUTES * 60 * 1000;
otpStore.set(phoneNumber, {
otp,
expiryTime,
attempts: config.OTP_MAX_ATTEMPTS
});
console.log(`Stored OTP ${otp} for ${phoneNumber}, expires at ${new Date(expiryTime).toISOString()}, attempts left: ${config.OTP_MAX_ATTEMPTS}`);
// Basic cleanup for expired OTPs (Not robust for production)
// This simple timeout might become inefficient at scale or under certain error conditions.
setTimeout(() => {
const record = otpStore.get(phoneNumber);
// Check if the record still exists, matches the OTP we set the timer for, and is expired
if (record && record.otp === otp && record.expiryTime <= Date.now()) {
otpStore.delete(phoneNumber);
console.log(`Expired OTP for ${phoneNumber} removed by timeout.`);
}
}_ config.OTP_EXPIRY_MINUTES * 60 * 1000 + 1000); // Check slightly after expiry
}
function verifyOtp(phoneNumber_ submittedOtp) {
const record = otpStore.get(phoneNumber);
if (!record) {
console.error(`Verification attempt for ${phoneNumber}: No OTP record found.`);
return { success: false_ message: 'OTP not found or expired.' };
}
if (Date.now() > record.expiryTime) {
console.error(`Verification attempt for ${phoneNumber}: OTP expired.`);
otpStore.delete(phoneNumber); // Clean up expired OTP
return { success: false, message: 'OTP has expired.' };
}
if (record.attempts <= 0) {
console.error(`Verification attempt for ${phoneNumber}: No attempts left.`);
// Optionally keep the record but prevent further attempts
return { success: false_ message: 'Maximum verification attempts exceeded.' };
}
if (record.otp !== submittedOtp) {
record.attempts -= 1;
otpStore.set(phoneNumber_ record); // Update attempts
console.warn(`Verification attempt for ${phoneNumber}: Incorrect OTP. Attempts left: ${record.attempts}`);
return { success: false_ message: `Incorrect OTP. ${record.attempts} attempts remaining.` };
}
// Success
console.log(`Verification successful for ${phoneNumber}`);
otpStore.delete(phoneNumber); // OTP is used_ remove it
return { success: true_ message: 'OTP verified successfully.' };
}
module.exports = {
generateOtp_
storeOtp_
verifyOtp_
};
AWS SNS Service
// src/services/snsService.js
const { SNSClient_ PublishCommand } = require('@aws-sdk/client-sns'); // Using v3 SDK
const config = require('../config');
// Configure AWS SDK v3 client
const snsClient = new SNSClient({
region: config.AWS_REGION_
credentials: {
accessKeyId: config.AWS_ACCESS_KEY_ID_
secretAccessKey: config.AWS_SECRET_ACCESS_KEY_
}_
});
async function sendSms(phoneNumber_ message) {
// Ensure phone number is in E.164 format_ required by SNS
if (!/^\+[1-9]\d{1_14}$/.test(phoneNumber)) {
console.error(`Invalid phone number format: ${phoneNumber}. Must be E.164.`);
throw new Error('Invalid phone number format. Must start with + and country code (E.164).');
}
const params = {
Message: message_
PhoneNumber: phoneNumber_
MessageAttributes: {
'AWS.SNS.SMS.SMSType': {
DataType: 'String'_
StringValue: config.SNS_SMS_TYPE_ // Use Transactional or Promotional from config
}_
// Optional: Set a Sender ID (if supported and registered in your region)
// 'AWS.SNS.SMS.SenderID': {
// DataType: 'String'_
// StringValue: 'MyAppOTP'_ // Example Sender ID
// }_
}_
};
try {
const command = new PublishCommand(params);
const data = await snsClient.send(command);
console.log(`Successfully sent SMS to ${phoneNumber}. MessageID: ${data.MessageId}`);
return { success: true_ messageId: data.MessageId };
} catch (err) {
console.error(`Error sending SMS via SNS to ${phoneNumber}:`_ err);
// Provide more specific feedback if possible
if (err.name === 'InvalidParameterException') {
throw new Error(`Failed to send SMS: Invalid parameter_ check phone number format (${phoneNumber}) or message content.`);
} else if (err.name === 'AuthorizationErrorException') {
throw new Error('Failed to send SMS: AWS credentials are invalid or lack permissions.');
}
// Rethrow a generic error for other cases
throw new Error(`Failed to send SMS: ${err.message}`);
}
}
module.exports = {
sendSms_
};
Building the API layer
Now_ let's connect the logic to Express endpoints.
OTP Controller
// src/controllers/otpController.js
const otpService = require('../services/otpService');
const snsService = require('../services/snsService');
const config = require('../config');
// Regex for E.164 phone number format validation
const E164_REGEX = /^\+[1-9]\d{1_14}$/;
// Dynamic regex for OTP based on configured length
const OTP_REGEX = new RegExp(`^\\d{${config.OTP_LENGTH}}$`);
async function requestOtp(req_ res) {
const { phoneNumber } = req.body;
// Validate phone number format (E.164 is required by AWS SNS)
if (!phoneNumber || !E164_REGEX.test(phoneNumber)) {
return res.status(400).json({ success: false_ message: 'Valid E.164 phone number is required (e.g._ +12223334444).' });
}
try {
const otp = otpService.generateOtp();
// Customize your SMS message as needed
const message = `Your verification code for MyApp is: ${otp}. It expires in ${config.OTP_EXPIRY_MINUTES} minutes.`;
// Store OTP before sending
otpService.storeOtp(phoneNumber_ otp);
// Send OTP via SMS using SNS service
await snsService.sendSms(phoneNumber_ message);
// Avoid logging OTP in production environments for security
console.log(`OTP requested for ${phoneNumber}. OTP: ${otp}`); // Log OTP only in dev/debug
res.status(200).json({ success: true_ message: `OTP sent successfully to ${phoneNumber}.` });
} catch (error) {
console.error('Error requesting OTP:'_ error);
// Avoid exposing internal error details to the client
res.status(500).json({ success: false_ message: 'Failed to send OTP. Please try again later.' });
}
}
async function verifyOtp(req_ res) {
const { phoneNumber_ otp } = req.body;
// Validate phone number format
if (!phoneNumber || !E164_REGEX.test(phoneNumber)) {
return res.status(400).json({ success: false_ message: 'Valid E.164 phone number is required.' });
}
// Validate OTP format dynamically based on config.OTP_LENGTH
if (!otp || !OTP_REGEX.test(otp)) {
return res.status(400).json({ success: false_ message: `A ${config.OTP_LENGTH}-digit OTP is required.` });
}
try {
const verificationResult = otpService.verifyOtp(phoneNumber_ otp);
if (verificationResult.success) {
res.status(200).json({ success: true_ message: verificationResult.message });
// Optional: Generate JWT token or session upon successful verification here
} else {
// Use 400 Bad Request for client-side errors like incorrect OTP_ expired_ max attempts exceeded
res.status(400).json({ success: false_ message: verificationResult.message });
}
} catch (error) { // Catch unexpected errors during verification process
console.error('Error verifying OTP:'_ error);
res.status(500).json({ success: false_ message: 'Failed to verify OTP due to an internal error. Please try again later.' });
}
}
module.exports = {
requestOtp_
verifyOtp_
};
OTP Routes
// src/routes/otpRoutes.js
const express = require('express');
const otpController = require('../controllers/otpController');
const router = express.Router();
// POST /api/otp/request-otp
router.post('/request-otp'_ otpController.requestOtp);
// POST /api/otp/verify-otp
router.post('/verify-otp'_ otpController.verifyOtp);
module.exports = router;
Express App Setup
// src/app.js
const express = require('express');
const otpRoutes = require('./routes/otpRoutes');
const config = require('./config'); // Load config early
const app = express();
// Middleware
app.use(express.json()); // Parse JSON request bodies
// Basic Logging Middleware (Consider using a more robust logger like Winston/Pino for production)
app.use((req_ res_ next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
next();
});
// --- Security Middleware (Rate Limiting) ---
// Apply rate limiting before the routes
// We will define these limiters in Section 7
// --- Routes ---
app.use('/api/otp', otpRoutes); // Mount OTP routes under /api/otp
// Health Check Endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// --- Error Handling ---
// Catch-all 404 for undefined routes
app.use((req, res, next) => {
res.status(404).json({ success: false, message: 'Resource not found.' });
});
// Global Error Handling Middleware (Must be defined LAST)
app.use((err, req, res, next) => {
console.error(""Unhandled Error:"", err.stack || err); // Log the full error stack trace
// Avoid sending stack trace details in production responses for security
res.status(500).json({ success: false, message: 'An unexpected internal server error occurred.' });
});
module.exports = app;
Server Entry Point
// src/server.js
const app = require('./app');
const config = require('./config');
const PORT = config.PORT;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`AWS Region: ${config.AWS_REGION}`);
console.log(`OTP Length: ${config.OTP_LENGTH}, Expiry: ${config.OTP_EXPIRY_MINUTES} mins, Max Attempts: ${config.OTP_MAX_ATTEMPTS}`);
console.log(`SNS SMS Type: ${config.SNS_SMS_TYPE}`);
// Warning about in-memory store
console.warn('--- WARNING: Using in-memory OTP store. Data will be lost on restart. Use Redis/DB for production. ---');
});
package.json
start script
Update Add a start script to package.json
for easy execution.
// package.json
{
""name"": ""node-sns-otp-api"",
""version"": ""1.0.0"",
""description"": ""Node.js Express API for OTP/2FA using AWS SNS"",
""main"": ""src/server.js"",
""scripts"": {
""start"": ""node src/server.js"",
""test"": ""echo \""Error: no test specified\"" && exit 1""
},
""keywords"": [
""otp"",
""2fa"",
""sms"",
""aws"",
""sns"",
""node"",
""express""
],
""author"": """",
""license"": ""ISC"",
""dependencies"": {
""@aws-sdk/client-sns"": ""^3.XXX.X"",
""dotenv"": ""^16.X.X"",
""express"": ""^4.X.X""
},
""devDependencies"": {}
}
(Note: Update dependency versions like ^3.XXX.X
in package.json
based on your actual installation)
Implementing error handling and logging
- Consistent Error Strategy: Our controllers return JSON responses with a
success
flag and amessage
. We use appropriate HTTP status codes (200 for success, 400 for bad requests/invalid OTPs, 404 for not found, 429 for rate limits, 500 for server errors). - Logging: We added basic
console.log
,console.warn
, andconsole.error
statements. For production, use a structured logger likewinston
orpino
to log in JSON format, include request IDs, control log levels (info
,warn
,error
), and potentially ship logs to a centralized logging service (like CloudWatch Logs). - SNS Errors: The
snsService.js
includes specific checks forInvalidParameterException
andAuthorizationErrorException
, providing clearer error messages when sending SMS fails. - Global Error Handler: The final
app.use((err, req, res, next) => ...)
inapp.js
catches any unhandled errors that occur during request processing, logs them, and returns a generic 500 error to the client. - Retry Mechanisms: For transient network issues when calling AWS SNS, you could implement a simple retry mechanism using libraries like
async-retry
. However, for OTPs, automatically retrying might send multiple SMS messages, which is usually undesirable. It's often better to return an error and let the user trigger a retry manually.
Database schema and data layer (Conceptual)
As mentioned, the in-memory store is unsuitable for production. If using Redis (recommended for OTPs due to speed and TTL features):
- Schema: Store data using keys like
otp:{phoneNumber}
(e.g.,otp:+12223334444
). - Data: Store a JSON string or a Redis Hash containing
{ otp: '123456', attempts: 3 }
. - Expiry: Set a Time-To-Live (TTL) on the Redis key equal to
config.OTP_EXPIRY_MINUTES
* 60 seconds. Redis handles automatic expiry efficiently.
If using a SQL database:
-
Schema:
CREATE TABLE otps ( phone_number VARCHAR(20) PRIMARY KEY, -- Store in E.164 format otp_code VARCHAR(10) NOT NULL, -- Adjust size based on OTP_LENGTH expires_at TIMESTAMP WITH TIME ZONE NOT NULL, -- Use timezone-aware timestamp attempts_remaining INT NOT NULL DEFAULT 3, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_otps_expires_at ON otps (expires_at); -- Index for efficient cleanup
-
Data Layer: Use an ORM (like Sequelize, Prisma) or a query builder (like Knex.js) to interact with the table, handling insertions, lookups (finding by
phone_number
), updates (decrementingattempts_remaining
), and deletions (on successful verification or expiry). Implement a background job or scheduled task to periodically delete rows whereexpires_at
is in the past.
Adding security features
-
Input Validation: We added basic validation for phone number (E.164) and OTP format using regex. For more robust validation (checking types, lengths, allowed characters, sanitizing inputs), use libraries like
express-validator
orjoi
. -
Rate Limiting: Crucial to prevent SMS pumping fraud (maliciously triggering SMS costs) and brute-force attacks on both requesting and verifying OTPs. Use
express-rate-limit
:npm install express-rate-limit
Update
src/app.js
to include and apply the limiters:// src/app.js const express = require('express'); const rateLimit = require('express-rate-limit'); // Import rate-limit const otpRoutes = require('./routes/otpRoutes'); const config = require('./config'); const app = express(); // Middleware app.use(express.json()); app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.originalUrl}`); next(); }); // --- Security Middleware (Rate Limiting) --- // Apply rate limiting BEFORE the routes they protect // Limiter for OTP requests (e.g., per IP) const otpRequestLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Limit each IP to 10 OTP requests per windowMs message: { success: false, 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 keyGenerator: (req) => req.ip, // Use IP address for rate limiting key }); // Stricter limiter for OTP verification attempts (e.g., per phone number OR IP) // Using phone number might be better against targeted attacks, but requires parsing body first // Using IP is simpler to implement here. Adjust 'max' based on OTP_MAX_ATTEMPTS. const otpVerifyLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 10, // Limit each IP to 10 verification attempts per windowMs (adjust carefully) message: { success: false, message: 'Too many verification attempts from this IP, please try again after 5 minutes' }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, // Use IP address }); // Apply limiters specifically to the OTP routes // Note: If otpRoutes handled more than just request/verify, apply more granularly within otpRoutes.js // or apply different limiters to different paths under /api/otp. app.use('/api/otp/request-otp', otpRequestLimiter); app.use('/api/otp/verify-otp', otpVerifyLimiter); // --- Routes --- app.use('/api/otp', otpRoutes); // Mount OTP routes AFTER limiters // Health Check Endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // --- Error Handling --- app.use((req, res, next) => { res.status(404).json({ success: false, message: 'Resource not found.' }); }); app.use((err, req, res, next) => { console.error(""Unhandled Error:"", err.stack || err); res.status(500).json({ success: false, message: 'An unexpected internal server error occurred.' }); }); module.exports = app;
-
Brute Force Protection (Verification): The
OTP_MAX_ATTEMPTS
logic implemented inotpService.js
provides specific protection against guessing the OTP for a given phone number. -
HTTPS: Always deploy your application behind a reverse proxy (like Nginx) or load balancer (like AWS ELB/ALB) that terminates SSL/TLS, ensuring all traffic is encrypted via HTTPS. Do not handle TLS directly in Node.js unless necessary.
-
Secure Credential Handling: Reiterate: Use
.env
for local development only. In production, inject secrets securely via your hosting environment's mechanisms (e.g., AWS Secrets Manager, Parameter Store, platform environment variables). Never commit secrets to Git. -
OTP Security Best Practices:
- Keep OTP expiry times reasonably short (e.g., 2-10 minutes).
- Use a cryptographically secure pseudo-random number generator (CSPRNG) like
crypto.randomInt
. - Do not log the actual OTP value in production environments. Our example logs it but includes a comment warning against it. Remove or conditionally disable this logging based on
NODE_ENV
. - Ensure OTPs are single-use (our
otpService.js
deletes the OTP upon successful verification).
Handling special cases
- Phone Number Formatting: Strictly enforce and normalize phone numbers to the E.164 format (
+
followed by country code and number, no spaces or symbols) before storing or sending to SNS. The validation regex/^\+[1-9]\d{1,14}$/
and checks insnsService.js
help enforce this. - Internationalization (i18n): The SMS message content (
message
variable inotpController.js
) is currently hardcoded in English. For multi-language support, you would need a localization library (likei18next
) and potentially pass a language preference from the client or detect it based on the phone number prefix to select the correct message template. - SNS Delivery Failures: Monitor SNS delivery status logs (see Section 10 - Note: Section 10 was not provided in the input, but mentioned here). Failures can occur due to invalid numbers, carrier issues, destination country regulations, or exceeding spending limits. Implement application logic to handle these failures gracefully, potentially informing the user or triggering alerts.
- Time Zones: Use UTC for storing and comparing expiry times (
Date.now()
provides milliseconds since epoch, which is timezone-agnostic). If using a database, ensureTIMESTAMP WITH TIME ZONE
data types are used to avoid ambiguity.
Implementing performance optimizations
- Persistent Data Store: Switching from the in-memory
Map
to Redis for OTP storage will significantly improve performance and scalability, especially under load, due to Redis's optimized in-memory operations and built-in TTL handling. - AWS SDK Client Reuse: The
SNSClient
is instantiated once insnsService.js
and reused for allsendSms
calls. This avoids the overhead of creating a new client and establishing connections for every request. - Asynchronous Operations: All potentially blocking I/O operations (like the
snsClient.send
call) correctly useasync/await
, preventing the Node.js event loop from being blocked and ensuring the server remains responsive.