code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. Generate a secure OTP.
  2. Send the OTP via SMS to a user's phone number using AWS SNS.
  3. Store the OTP temporarily with an expiry time and attempt limits.
  4. 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:

mermaid
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.

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir node-sns-otp-api
    cd node-sns-otp-api
  2. Initialize Node.js Project: This creates a package.json file.

    bash
    npm init -y
  3. Install Dependencies: We need Express for the server, the modular AWS SDK v3 client for SNS, and dotenv for environment variables.

    bash
    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.
  4. Create Project Structure: Organize the project for clarity.

    bash
    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.
  5. Configure .gitignore: Add node_modules and .env to prevent committing them.

    text
    node_modules/
    .env
    *.log
  6. 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.

    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 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.

  1. 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.
  2. Update .env file: Paste the copied credentials and your chosen region into your .env file:

    Code
    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
  3. 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.

Configuration Loader

javascript
// 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.

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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.

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 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 (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.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 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

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.