Implementing Node.js OTP/2FA with Express and AWS SNS
A comprehensive guide to building a secure One-Time Password (OTP) system using Node.js, Express, and AWS SNS for SMS delivery, covering setup, implementation, security, and deployment.
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.
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:
Add node_modules and .env to prevent committing them.
Text
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, and YOUR_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.
.env
# 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, like us-east-1, eu-west-1, ap-southeast-2). See AWS Regions and Endpoints documentation for details.
SNS_SMS_TYPE: Set to Transactional 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 only sns: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:
.env
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) or Promotional 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.
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.
JavaScript
// src/services/otpService.jsconst crypto = require('crypto');
const config = require('../config');
// In-memory store (Replace with Redis/DB for production)const otpStore = newMap(); // Using Map for better performance than object for frequent adds/deletesfunctiongenerateOtp() {
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
}
functionstoreOtp(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 ${newDate(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 expiredif (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
}
functionverifyOtp(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 OTPreturn { 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 attemptsreturn { success: false, message: 'Maximum verification attempts exceeded.' };
}
if (record.otp !== submittedOtp) {
record.attempts -= 1;
otpStore.set(phoneNumber, record); // Update attemptsconsole.warn(`Verification attempt for ${phoneNumber}: Incorrect OTP. Attempts left: ${record.attempts}`);
return { success: false, message: `Incorrect OTP. ${record.attempts} attempts remaining.` };
}
// Successconsole.log(`Verification successful for ${phoneNumber}`);
otpStore.delete(phoneNumber); // OTP is used, remove itreturn { success: true, message: 'OTP verified successfully.' };
}
module.exports = {
generateOtp,
storeOtp,
verifyOtp,
};
AWS SNS Service
JavaScript
// src/services/snsService.jsconst { SNSClient, PublishCommand } = require('@aws-sdk/client-sns'); // Using v3 SDKconst config = require('../config');
// Configure AWS SDK v3 clientconst snsClient = newSNSClient({
region: config.AWS_REGION,
credentials: {
accessKeyId: config.AWS_ACCESS_KEY_ID,
secretAccessKey: config.AWS_SECRET_ACCESS_KEY,
},
});
asyncfunctionsendSms(phoneNumber, message) {
// Ensure phone number is in E.164 format, required by SNSif (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
console.error(`Invalid phone number format: ${phoneNumber}. Must be E.164.`);
thrownewError('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 = newPublishCommand(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 possibleif (err.name === 'InvalidParameterException') {
thrownewError(`Failed to send SMS: Invalid parameter, check phone number format (${phoneNumber}) or message content.`);
} elseif (err.name === 'AuthorizationErrorException') {
thrownewError('Failed to send SMS: AWS credentials are invalid or lack permissions.');
}
// Rethrow a generic error for other casesthrownewError(`Failed to send SMS: ${err.message}`);
}
}
module.exports = {
sendSms,
};
Building the API layer
Now, let's connect the logic to Express endpoints.
OTP Controller
JavaScript
// src/controllers/otpController.jsconst otpService = require('../services/otpService');
const snsService = require('../services/snsService');
const config = require('../config');
// Regex for E.164 phone number format validationconstE164_REGEX = /^\+[1-9]\d{1,14}$/;
// Dynamic regex for OTP based on configured lengthconstOTP_REGEX = newRegExp(`^\\d{${config.OTP_LENGTH}}$`);
asyncfunctionrequestOtp(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 neededconst 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 serviceawait snsService.sendSms(phoneNumber, message);
// Avoid logging OTP in production environments for securityconsole.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.' });
}
}
asyncfunctionverifyOtp(req, res) {
const { phoneNumber, otp } = req.body;
// Validate phone number formatif (!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_LENGTHif (!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 processconsole.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,
};
// src/app.jsconst express = require('express');
const otpRoutes = require('./routes/otpRoutes');
const config = require('./config'); // Load config earlyconst 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(`${newDate().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: newDate().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(""UnhandledError:"", 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
JavaScript
// src/server.jsconst app = require('./app');
const config = require('./config');
constPORT = 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 storeconsole.warn('--- WARNING: Using in-memory OTP store. Data will be lost on restart. Use Redis/DB for production. ---');
});
Update package.json start script
Add a start script to package.json for easy execution.
JSON
// 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 a message. 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, and console.error statements. For production, use a structured logger like winston or pino 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 for InvalidParameterException and AuthorizationErrorException, providing clearer error messages when sending SMS fails.
Global Error Handler: The final app.use((err, req, res, next) => ...) in app.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:
SQL
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 TIMESTAMPWITHTIME ZONE NOT NULL, -- Use timezone-aware timestamp
attempts_remaining INTNOT NULLDEFAULT3,
created_at TIMESTAMPWITHTIME ZONE DEFAULTCURRENT_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 (decrementing attempts_remaining), and deletions (on successful verification or expiry). Implement a background job or scheduled task to periodically delete rows where expires_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 or joi.
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:
Bash
npm install express-rate-limit
Update src/app.js to include and apply the limiters:
JavaScript
// src/app.jsconst express = require('express');
const rateLimit = require('express-rate-limit'); // Import rate-limitconst otpRoutes = require('./routes/otpRoutes');
const config = require('./config');
const app = express();
// Middleware
app.use(express.json());
app.use((req, res, next) => {
console.log(`${newDate().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 minutesmax: 10, // Limit each IP to 10 OTP requests per windowMsmessage: { 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-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headerskeyGenerator: (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 minutesmax: 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: newDate().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(""UnhandledError:"", 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 in otpService.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 in snsService.js help enforce this.
Internationalization (i18n): The SMS message content (message variable in otpController.js) is currently hardcoded in English. For multi-language support, you would need a localization library (like i18next) 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, ensure TIMESTAMP 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 in snsService.js and reused for all sendSms 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 use async/await, preventing the Node.js event loop from being blocked and ensuring the server remains responsive.
Frequently asked questions
Use the AWS SDK for JavaScript v3 (@aws-sdk/client-sns) to interact with the SNS service. Configure the SNSClient with your AWS credentials and region. The sendSms function in the provided snsService.js example demonstrates how to publish SMS messages using the PublishCommand, including setting the message type (Transactional or Promotional).
The AWS SDK for JavaScript v3 (@aws-sdk/client-sns) is the recommended way to integrate with AWS SNS in Node.js projects. It offers a modular architecture, improved performance, and better security compared to previous versions.
Transactional SMS messages have higher delivery priority and are more likely to reach users, even those on Do Not Disturb (DND) lists. This makes them ideal for time-sensitive messages like OTPs, ensuring users receive their codes promptly, though they may cost slightly more than Promotional messages.
You should move your AWS account out of the SNS sandbox when you're ready to send SMS messages to unverified phone numbers and need higher spending limits. While in the sandbox, you can only send SMS to verified numbers and have restricted spending.
No, an in-memory store like a JavaScript Map is not suitable for production OTP storage. Data will be lost if the server restarts. Use a persistent store like Redis or DynamoDB for production environments to maintain OTP data reliably.
The provided otpService.js example uses crypto.randomInt to generate secure OTPs of a configurable length (OTP_LENGTH). It pads the OTP with leading zeros to maintain a consistent format.
You need an AWS account, Node.js and npm installed, basic understanding of Node.js, Express, and REST APIs, and access to a phone number for testing.
The verifyOtp function in otpService.js handles OTP verification. It retrieves the stored OTP, checks expiry and attempts remaining. If the submitted OTP matches the stored value, it marks the OTP as used. If the attempt is unsuccessful the number of allowed attempts is decreased.
Rate limiting is crucial to prevent SMS pumping fraud and brute-force attacks. It limits the number of OTP requests and verification attempts from a given IP address within a specified time window.
E.164 is an international standard for phone number formatting, ensuring consistent representation of numbers. It's required by AWS SNS for sending SMS messages and helps avoid formatting issues.
Use keys like otp:{phoneNumber} to store OTP data in Redis. Store data as a JSON string or Redis Hash including the OTP, attempts remaining, and set a TTL for automatic expiry.
Use short expiry times, CSPRNG for generation, avoid logging OTPs in production, ensure single-use, enforce E.164 format, and implement rate limiting.
The snsService.js example includes checks for specific SNS errors like InvalidParameterException and AuthorizationErrorException, providing clearer feedback if sending fails.
Redis is recommended due to its speed and TTL features, which align well with OTP requirements. A SQL database with proper indexing and cleanup mechanisms could also be used if you need more structured data storage.
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:
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:
@aws-sdk/client-sns): To interact with AWS services programmatically using the recommended modular client.Architecture:
Prerequisites:
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-apiInitialize Node.js Project: This creates a
package.jsonfile.Install Dependencies: We need Express for the server, the modular AWS SDK v3 client for SNS, and dotenv for environment variables.
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.envfile.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 .gitignoresrc/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_modulesand.envto prevent committing them.Configure
.env: Create a.envfile 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_REGIONwith your actual AWS credentials and chosen region. Never commit your.envfile containing real secrets to version control (like Git). Use the.gitignorefile to prevent this. For production environments, use more secure methods like AWS Secrets Manager or environment variables provided by your hosting platform.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 toTransactionalfor higher delivery priority, especially for OTPs, even to numbers on Do Not Disturb (DND) lists (may incur slightly higher costs).Promotionalis 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.
sns-otp-service-user).AmazonSNSFullAccesspolicy. Note: For production, create a more restrictive custom policy granting onlysns:Publishpermissions.Update
.envfile: Paste the copied credentials and your chosen region into your.envfile:Configure AWS SNS Settings (AWS Console):
+12223334444).Transactional(recommended for OTPs) orPromotionalbased on your.envsetting and use case.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
Mapas 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 basicsetTimeoutcleanup 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. ---'); });Update
package.jsonstart scriptAdd a start script to
package.jsonfor 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.Xinpackage.jsonbased on your actual installation)Implementing error handling and logging
successflag 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).console.log,console.warn, andconsole.errorstatements. For production, use a structured logger likewinstonorpinoto 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).snsService.jsincludes specific checks forInvalidParameterExceptionandAuthorizationErrorException, providing clearer error messages when sending SMS fails.app.use((err, req, res, next) => ...)inapp.jscatches any unhandled errors that occur during request processing, logs them, and returns a generic 500 error to the client.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):
otp:{phoneNumber}(e.g.,otp:+12223334444).{ otp: '123456', attempts: 3 }.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 cleanupData 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_atis 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-validatororjoi.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:Update
src/app.jsto 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_ATTEMPTSlogic implemented inotpService.jsprovides 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
.envfor 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:
crypto.randomInt.NODE_ENV.otpService.jsdeletes the OTP upon successful verification).Handling special cases
+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.jshelp enforce this.messagevariable 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.Date.now()provides milliseconds since epoch, which is timezone-agnostic). If using a database, ensureTIMESTAMP WITH TIME ZONEdata types are used to avoid ambiguity.Implementing performance optimizations
Mapto 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.SNSClientis instantiated once insnsService.jsand reused for allsendSmscalls. This avoids the overhead of creating a new client and establishing connections for every request.snsClient.sendcall) correctly useasync/await, preventing the Node.js event loop from being blocked and ensuring the server remains responsive.