code examples
code examples
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.
- 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.
bashmkdir node-sns-otp-api cd node-sns-otp-api -
Initialize Node.js Project: This creates a
package.jsonfile.bashnpm init -y -
Install Dependencies: We need Express for the server, the modular AWS SDK v3 client for SNS, and dotenv for environment variables.
bashnpm install express @aws-sdk/client-sns dotenvexpress: 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.
bashmkdir 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.textnode_modules/ .env *.log -
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.Code# 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 PromotionalAWS_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.
- 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
AmazonSNSFullAccesspolicy. Note: For production, create a more restrictive custom policy granting onlysns:Publishpermissions. - 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
.envfile: Paste the copied credentials and your chosen region into your.envfile:CodeAWS_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) orPromotionalbased on your.envsetting 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. ---');
});Update package.json start script
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
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). - Logging: We added basic
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). - SNS Errors: The
snsService.jsincludes specific checks forInvalidParameterExceptionandAuthorizationErrorException, providing clearer error messages when sending SMS fails. - Global Error Handler: The final
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. - 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:
sqlCREATE 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_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:bashnpm install express-rate-limitUpdate
src/app.jsto include and apply the limiters:javascript// 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:
- 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.jsdeletes 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.jshelp enforce this. - Internationalization (i18n): The SMS message content (
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. - 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 ZONEdata types are used to avoid ambiguity.
Implementing performance optimizations
- Persistent Data Store: Switching from the in-memory
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. - AWS SDK Client Reuse: The
SNSClientis instantiated once insnsService.jsand reused for allsendSmscalls. 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.sendcall) correctly useasync/await, preventing the Node.js event loop from being blocked and ensuring the server remains responsive.
Frequently Asked Questions
How to send OTP SMS messages using AWS SNS?
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).
What is the recommended AWS SDK for Node.js SNS integration?
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.
Why use Transactional SMS type for OTP delivery?
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.
When should I move my AWS account out of the SNS sandbox?
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.
Can I use an in-memory store for OTPs in production?
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.
How to implement OTP generation in Node.js?
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.
What are the prerequisites for setting up this OTP project?
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.
How to verify an OTP submitted by a user?
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.
Why is rate limiting important for OTP APIs?
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.
What is the purpose of the E.164 phone number format?
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.
How to store OTPs securely in Redis?
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.
What are some best practices for OTP security?
Use short expiry times, CSPRNG for generation, avoid logging OTPs in production, ensure single-use, enforce E.164 format, and implement rate limiting.
How does the example code handle AWS SNS errors?
The snsService.js example includes checks for specific SNS errors like InvalidParameterException and AuthorizationErrorException, providing clearer feedback if sending fails.
What database options are recommended for storing OTP data in production?
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.