code examples

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

Implementing OTP/2FA with Fastify and AWS SNS

A step-by-step guide to building an SMS-based OTP/2FA system using Fastify, Node.js, and AWS SNS.

Implementing OTP/2FA with Fastify and AWS SNS

This guide provides a step-by-step walkthrough for building a production-ready One-Time Password (OTP) / Two-Factor Authentication (2FA) system using Fastify, Node.js, and AWS Simple Notification Service (SNS) for SMS delivery.

We'll cover everything from initial project setup and AWS configuration to implementing core OTP logic, securing endpoints, handling errors, and preparing for deployment.

Project Overview and Goals

Goal: To add a secure SMS-based OTP verification layer to a Fastify application. This is commonly used for phone number verification during signup, implementing 2FA for logins, or authorizing sensitive actions.

Problem Solved: Enhances application security by verifying user possession of a specific phone number, mitigating unauthorized access and fraudulent activities.

Technologies:

  • Node.js: The runtime environment.
  • Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features like built-in schema validation.
  • AWS SNS (Simple Notification Service): A managed messaging service used here to reliably send OTP codes via SMS to users' phones globally. Chosen for its scalability, cost-effectiveness, and integration ease.
  • fastify-aws-sns: A community Fastify plugin simplifying interaction with the AWS SNS API.
  • @fastify/env: For loading and validating environment variables.
  • @fastify/sensible: Provides sensible defaults, including standard HTTP errors.
  • @fastify/rate-limit: To protect against brute-force attacks on OTP endpoints.
  • ioredis & @fastify/redis (Recommended): For temporarily storing OTPs securely and efficiently with Time-To-Live (TTL). An in-memory store will be used initially for simplicity, but Redis is strongly advised for production.

System Architecture:

+---------+ (1) Request OTP +-----------------+ (3) Send SMS via SNS +--------------+ | User | -------------------------> | Fastify Server | -----------------------------> | AWS SNS | --(SMS)--> User's Phone | Browser/| (Phone Number) | (Node.js App) | (OTP Code, Phone #) +--------------+ | App | +-----------------+ +---------+ | ^ | | (2) | (5) | | Generate & Store OTP | (4) Submit OTP | | Verify OTP | (Phone Number, OTP Code) v | | +---------------+ +-------------------------------> | OTP Store | | (Redis / Mem) | +---------------+

Note: Step 3 implicitly includes SNS delivering the SMS to the user's phone.

Outcome: A Fastify application with two API endpoints: one to request an OTP sent to a provided phone number via SMS, and another to verify the submitted OTP against the stored value.

Prerequisites:

  • Node.js (v18 or later recommended) and npm installed.
  • An AWS account with permissions to manage IAM users and SNS.
  • Basic understanding of JavaScript, Node.js, REST APIs, and Fastify fundamentals.
  • Access to a terminal or command prompt.
  • (Optional but Recommended) Redis server accessible for OTP storage in production.

1. Setting up the Project

Let's initialize our Node.js project and install the necessary dependencies.

1.1 Initialize Project:

Open your terminal and create a new project directory:

bash
mkdir fastify-sns-otp
cd fastify-sns-otp
npm init -y

This creates a package.json file.

1.2 Install Dependencies:

Install Fastify and essential plugins:

bash
npm install fastify fastify-aws-sns @fastify/env @fastify/sensible @fastify/rate-limit ioredis @fastify/redis
  • fastify: The core framework.
  • fastify-aws-sns: Plugin for AWS SNS integration.
  • @fastify/env: For managing environment variables.
  • @fastify/sensible: Provides useful utilities like HTTP errors.
  • @fastify/rate-limit: To prevent abuse of OTP endpoints.
  • ioredis: A robust Redis client (recommended for production OTP storage).
  • @fastify/redis: Fastify plugin to integrate ioredis.

Install development dependencies (like pino-pretty for logging):

bash
npm install --save-dev pino-pretty tap sinon # tap & sinon for testing

1.3 Project Structure:

Create the basic directory structure:

bash
mkdir src
mkdir src/routes
mkdir src/services
mkdir src/config
mkdir test
mkdir test/routes
touch src/server.js
touch src/app.js
touch src/routes/otp.js
touch src/services/otpService.js
touch src/config/environment.js
touch test/setup.js
touch test/routes/otp.test.js
touch .env
touch .env.example
touch .gitignore
  • src/: Main application code.
  • src/routes/: API route definitions.
  • src/services/: Business logic (OTP generation, verification, SNS interaction).
  • src/config/: Configuration files (environment variables schema).
  • test/: Automated tests.
  • src/server.js: Entry point to start the Fastify server.
  • src/app.js: Fastify application setup (plugins, routes).
  • .env: Stores sensitive credentials and environment-specific settings (DO NOT commit this file).
  • .env.example: A template showing required environment variables.
  • .gitignore: Specifies files/directories Git should ignore.

1.4 Configure .gitignore:

Add the following to your .gitignore file to avoid committing sensitive data and unnecessary files:

text
# Dependencies
node_modules

# Environment Variables
.env
*.env.local
*.env.*.local

# Logs
*.log
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Build output
dist
build

# OS generated files
.DS_Store
Thumbs.db

1.5 Configure Environment Variables Schema:

Define the expected environment variables in src/config/environment.js:

javascript
// src/config/environment.js
const environmentSchema = {
  type: 'object',
  required: [
    'PORT',
    'HOST',
    'AWS_ACCESS_KEY_ID',
    'AWS_SECRET_ACCESS_KEY',
    'AWS_DEFAULT_REGION',
    // Optional but recommended for production
    // 'REDIS_URL'
  ],
  properties: {
    PORT: { type: 'string', default: '3000' },
    HOST: { type: 'string', default: '0.0.0.0' },
    AWS_ACCESS_KEY_ID: { type: 'string' },
    AWS_SECRET_ACCESS_KEY: { type: 'string' },
    AWS_DEFAULT_REGION: { type: 'string' }, // e.g., 'us-east-1'
    OTP_LENGTH: { type: 'integer', default: 6 }, // Removed from required as it has a default
    OTP_TTL_SECONDS: { type: 'integer', default: 300 }, // 5 minutes, removed from required as it has a default
    REDIS_URL: { type: 'string' }, // e.g., 'redis://localhost:6379'
    LOG_LEVEL: { type: 'string', default: 'info'}, // trace, debug, info, warn, error, fatal
    RATE_LIMIT_MAX_REQUESTS: { type: 'integer', default: 5 }, // Max requests per window
    RATE_LIMIT_WINDOW_MS: { type: 'integer', default: 60000 }, // Window size in milliseconds (1 minute)
    SNS_SMS_SENDER_ID: { type: 'string', default: 'MyApp'}, // Optional custom sender ID (max 11 chars, check regional support)
    SNS_SMS_TYPE: { type: 'string', default: 'Transactional' } // 'Transactional' or 'Promotional'
  }
};

export default environmentSchema;

1.6 Set up .env.example:

Populate .env.example as a template for other developers (and yourself):

dotenv
# .env.example

# Server Configuration
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info

# AWS SNS Configuration
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION=us-east-1 # Change to your desired AWS region
SNS_SMS_SENDER_ID=MyApp # Optional: Custom Sender ID (check AWS docs for regional support & rules)
SNS_SMS_TYPE=Transactional # 'Transactional' for higher reliability, 'Promotional' for lower cost

# OTP Configuration
OTP_LENGTH=6
OTP_TTL_SECONDS=300 # 5 minutes

# Rate Limiting
RATE_LIMIT_MAX_REQUESTS=5
RATE_LIMIT_WINDOW_MS=60000 # 1 minute

# Redis Configuration (Recommended for Production)
# REDIS_URL=redis://localhost:6379

1.7 Create .env file:

Copy .env.example to .env and fill in your actual AWS credentials and any other desired settings. Remember to keep this file secure and out of version control.

bash
cp .env.example .env
# Now edit .env with your real credentials

2. AWS SNS Configuration

We need an IAM user with specific permissions to send SMS messages via SNS.

2.1 Create an IAM User:

  1. Navigate to the IAM console in your AWS account: https://console.aws.amazon.com/iam/
  2. Go to Users and click Create user.
  3. Enter a User name (e.g., fastify-sns-otp-service).
  4. Select Provide user access to the AWS Management Console - optional if you don't need console access for this user. If you do, set up a password or let AWS generate one.
  5. Click Next.
  6. Choose Attach policies directly.
  7. Search for and select the AmazonSNSFullAccess policy for simplicity. Security Best Practice: For production, create a custom inline policy granting only the sns:Publish permission to minimize attack surface.
    • Example Custom Policy (Least Privilege):
      json
      {
          ""Version"": ""2012-10-17"",
          ""Statement"": [
              {
                  ""Effect"": ""Allow"",
                  ""Action"": ""sns:Publish"",
                  ""Resource"": ""*""
              }
          ]
      }
      Note: You could restrict the Resource further to specific Topic ARNs if using topics, but for direct SMS publishing, * is often necessary unless specific controls are in place.
  8. Click Next.
  9. Review the user details and permissions, then click Create user.

2.2 Obtain Access Keys:

  1. After the user is created, click on the username in the user list.
  2. Go to the Security credentials tab.
  3. Scroll down to Access keys and click Create access key.
  4. Select Application running outside AWS (or Command Line Interface (CLI) if applicable).
  5. Understand the recommendation (alternatives exist, but access keys are needed here). Click Next.
  6. Optionally add a description tag (e.g., fastify-sns-otp-app).
  7. Click Create access key.
  8. Crucial: Immediately copy the Access key ID and Secret access key. You cannot retrieve the secret key again after closing this window. Store them securely.
  9. Update your .env file with these keys:
    dotenv
    # .env
    AWS_ACCESS_KEY_ID=COPIED_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY=COPIED_SECRET_ACCESS_KEY
    AWS_DEFAULT_REGION=your-chosen-region # e.g., us-east-1

2.3 Configure SNS Defaults (Optional but Recommended):

While fastify-aws-sns allows setting SMS type per message, you can set account-wide defaults in the SNS console for consistency.

  1. Navigate to the SNS console: https://console.aws.amazon.com/sns/
  2. In the left navigation pane, choose Text messaging (SMS).
  3. Under Account spending limit, click Edit and set a reasonable monthly limit to prevent unexpected costs (e.g., $1.00 initially). Save changes.
  4. Under Default settings for text messages, click Edit.
  5. Set the Default message type to Transactional for OTP messages (optimized for reliability).
  6. You can optionally configure a Default sender ID here, but be aware of regional support and regulations. Using the SNS_SMS_SENDER_ID environment variable allows more flexibility per application.
  7. Save changes.

3. Implementing Core Functionality (OTP Service)

Now, let's write the logic for generating, storing, and verifying OTPs. We'll use an in-memory store initially and show Redis integration later (Section 6).

3.1 OTP Service (src/services/otpService.js):

javascript
// src/services/otpService.js
import crypto from 'node:crypto';

// --- In-Memory Store (Simple Example - Replace with Redis in Section 6) ---
const otpStore = new Map();

/**
 * Stores the OTP associated with a phone number in memory with TTL.
 * IMPORTANT: This is NOT suitable for production due to lack of persistence.
 * @param {import('fastify').FastifyInstance} fastify - Fastify instance for logging.
 * @param {string} phoneNumber - The user's phone number (key).
 * @param {string} otp - The OTP code (value).
 * @param {number} ttlSeconds - Time-to-live in seconds.
 */
async function storeOtpMemory(fastify, phoneNumber, otp, ttlSeconds) {
  const expiry = Date.now() + ttlSeconds * 1000;
  otpStore.set(phoneNumber, { otp, expiry });

  // Simple in-memory cleanup - Prone to issues if server restarts
  setTimeout(() => {
    const stored = otpStore.get(phoneNumber);
    if (stored && stored.otp === otp) { // Ensure we delete the correct OTP if regenerated quickly
        otpStore.delete(phoneNumber);
        fastify.log.info(`Expired OTP for ${phoneNumber} removed from memory.`);
    }
  }, ttlSeconds * 1000);

  fastify.log.info(`OTP ${otp} stored in memory for ${phoneNumber}, expires in ${ttlSeconds}s.`);
}

/**
 * Verifies the submitted OTP against the one stored in memory.
 * @param {import('fastify').FastifyInstance} fastify - Fastify instance for logging.
 * @param {string} phoneNumber - The user's phone number.
 * @param {string} submittedOtp - The OTP submitted by the user.
 * @returns {Promise<boolean>} True if OTP is valid and not expired, false otherwise.
 */
async function verifyOtpMemory(fastify, phoneNumber, submittedOtp) {
  const storedData = otpStore.get(phoneNumber);

  if (!storedData) {
    fastify.log.warn(`Verification failed: No OTP found in memory for ${phoneNumber}.`);
    return false; // No OTP found for this number
  }

  const { otp: storedOtp, expiry } = storedData;

  if (Date.now() > expiry) {
    fastify.log.warn(`Verification failed: OTP expired for ${phoneNumber}.`);
    otpStore.delete(phoneNumber); // Clean up expired OTP
    return false; // OTP expired
  }

  if (storedOtp === submittedOtp) {
    fastify.log.info(`Verification successful for ${phoneNumber}. Removing from memory.`);
    otpStore.delete(phoneNumber); // OTP is valid, remove it to prevent reuse
    return true; // OTP matches
  }

  fastify.log.warn(`Verification failed: Incorrect OTP for ${phoneNumber}.`);
  return false; // OTP does not match
}
// --- End In-Memory Store ---


/**
 * Generates a secure random OTP.
 * @param {number} length - The desired length of the OTP.
 * @returns {string} The generated OTP.
 */
function generateOtp(length) {
  // Ensure length is positive integer
  const otpLength = Math.max(1, Math.floor(length));
  // Calculate the minimum and maximum values for the OTP
  const min = Math.pow(10, otpLength - 1);
  const max = Math.pow(10, otpLength) - 1;
  // Generate a cryptographically secure random number within the range
  return (crypto.randomInt(min, max + 1)).toString().padStart(otpLength, '0');
}


// --- Redis-based Store (See Section 6 for implementation) ---
// Placeholder functions, will be implemented in Section 6
async function storeOtpRedis(fastify, phoneNumber, otp, ttlSeconds) {
    throw new Error('storeOtpRedis not implemented yet');
}
async function verifyOtpRedis(fastify, phoneNumber, submittedOtp) {
    throw new Error('verifyOtpRedis not implemented yet');
}
// --- End Redis Store ---


// Choose which store implementation to use based on Redis availability
// The actual selection logic will be in the route handlers or app setup
export {
    generateOtp,
    storeOtpMemory,
    verifyOtpMemory,
    storeOtpRedis, // Export Redis functions for later use
    verifyOtpRedis
};
  • generateOtp: Creates a random numeric OTP using Node.js's crypto module.
  • storeOtpMemory/verifyOtpMemory: Initial implementation using an in-memory Map with setTimeout for TTL. Marked clearly as non-production.
  • Redis function placeholders (storeOtpRedis/verifyOtpRedis) are included for later implementation in Section 6.

4. Building the API Layer (Fastify Routes)

Define the API endpoints for requesting and verifying OTPs.

4.1 OTP Routes (src/routes/otp.js):

javascript
// src/routes/otp.js
import {
    generateOtp,
    storeOtpMemory, // Using memory store initially
    verifyOtpMemory, // Using memory store initially
    storeOtpRedis,   // Will use these later
    verifyOtpRedis
} from '../services/otpService.js';

// --- Schemas ---
// Note on OTP pattern: Accessing fastify.config here is difficult as schemas
// are often defined at module load time before config is ready. Using process.env
// is a common workaround, though less ideal than using fastify.config directly.
// Ensure OTP_LENGTH is reliably set in the environment where the app runs.
const otpPattern = `^\\d{${process.env.OTP_LENGTH || 6}}`; // Default to 6 if env var not set *at load time*

const requestOtpSchema = {
  body: {
    type: 'object',
    required: ['phoneNumber'],
    properties: {
      // Basic E.164 format validation (adjust regex as needed for stricter rules)
      phoneNumber: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}' }
    },
    additionalProperties: false
  },
  response: {
    200: {
      type: 'object',
      properties: {
        message: { type: 'string' },
        messageId: { type: 'string' } // From SNS
      }
    }
    // Fastify automatically handles 4xx/5xx based on @fastify/sensible
  }
};

const verifyOtpSchema = {
  body: {
    type: 'object',
    required: ['phoneNumber', 'otp'],
    properties: {
      phoneNumber: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}' },
      otp: { type: 'string', pattern: otpPattern } // Use defined pattern
    },
    additionalProperties: false
  },
  response: {
    200: {
      type: 'object',
      properties: {
        success: { type: 'boolean' },
        message: { type: 'string' }
      }
    },
    400: { // Example explicit error response
        type: 'object',
        properties: {
            statusCode: { type: 'integer' },
            error: { type: 'string' },
            message: { type: 'string' }
        }
    }
    // Fastify automatically handles 4xx/5xx based on @fastify/sensible
  }
};
// --- End Schemas ---


async function otpRoutes(fastify, options) {
  // Apply rate limiting specifically to OTP routes
  const rateLimitOptions = {
    max: fastify.config.RATE_LIMIT_MAX_REQUESTS,
    timeWindow: fastify.config.RATE_LIMIT_WINDOW_MS,
    // Redis store will be added by the rate-limit plugin itself if Redis is available
  };

  // Determine which storage functions to use based on Redis availability
  const useRedis = !!fastify.redis;
  const storeOtp = useRedis ? storeOtpRedis : storeOtpMemory;
  const verifyOtp = useRedis ? verifyOtpRedis : verifyOtpMemory;

  if (useRedis) {
      fastify.log.info('OTP routes will use Redis for storage.');
  } else {
      fastify.log.warn('OTP routes using in-memory storage (NOT FOR PRODUCTION).');
  }

  // === Request OTP Endpoint ===
  fastify.post('/request-otp', { schema: requestOtpSchema, config: { rateLimit: rateLimitOptions } }, async (request, reply) => {
    const { phoneNumber } = request.body;
    const otp = generateOtp(fastify.config.OTP_LENGTH);
    const message = `Your verification code is: ${otp}`;

    // Check if Redis is required but unavailable
    if (!useRedis && fastify.config.REDIS_URL) {
        fastify.log.error(`Cannot request OTP for ${phoneNumber}: Redis configured but unavailable.`);
        throw fastify.httpErrors.serviceUnavailable('OTP service dependency temporarily unavailable. Please try again later.');
    }

    try {
      fastify.log.info(`Requesting OTP for ${phoneNumber}`);

      // Send OTP via AWS SNS using the plugin
      const snsResult = await fastify.snsSMS.publish({
        message: message,
        phoneNumber: phoneNumber,
        messageAttributes: { // Optional: Set message type and sender ID if needed
            'AWS.SNS.SMS.SMSType': {
                DataType: 'String',
                StringValue: fastify.config.SNS_SMS_TYPE // Transactional or Promotional
            },
            'AWS.SNS.SMS.SenderID': {
                DataType: 'String',
                StringValue: fastify.config.SNS_SMS_SENDER_ID // Custom Sender ID
            }
        }
      });

      fastify.log.info(`SNS publish successful for ${phoneNumber}, MessageID: ${snsResult.MessageId}`);

      // Store the OTP using the selected storage method
      await storeOtp(fastify, phoneNumber, otp, fastify.config.OTP_TTL_SECONDS);

      return reply.code(200).send({
          message: 'OTP requested successfully. Check your phone.',
          messageId: snsResult.MessageId
        });

    } catch (error) {
      fastify.log.error({ err: error, phoneNumber }, `Failed to send or store OTP for ${phoneNumber}`);

      // Check for specific known errors
      if (error.message?.includes('unavailable')) { // Check for our Redis unavailable errors
           throw fastify.httpErrors.serviceUnavailable('OTP service temporarily unavailable.');
      }
      if (error.code === 'InvalidParameterException') {
          // Often due to invalid phone number format for SNS region
          throw fastify.httpErrors.badRequest('Invalid phone number format or region unsupported.');
      }
      if (error.code === 'AuthorizationError' || error.code === 'AccessDenied') {
          throw fastify.httpErrors.internalServerError('SNS authorization configuration error.');
      }
       // Let @fastify/sensible handle generic errors
      throw fastify.httpErrors.internalServerError('Failed to process OTP request.');
    }
  });

  // === Verify OTP Endpoint ===
  fastify.post('/verify-otp', { schema: verifyOtpSchema, config: { rateLimit: rateLimitOptions } }, async (request, reply) => {
    const { phoneNumber, otp } = request.body;

    // Check if Redis is required but unavailable
    if (!useRedis && fastify.config.REDIS_URL) {
        fastify.log.error(`Cannot verify OTP for ${phoneNumber}: Redis configured but unavailable.`);
        throw fastify.httpErrors.serviceUnavailable('OTP service dependency temporarily unavailable. Please try again later.');
    }

    fastify.log.info(`Verifying OTP for ${phoneNumber}`);

    try {
      // Verify using the selected storage method
      const isValid = await verifyOtp(fastify, phoneNumber, otp);

      if (isValid) {
        return reply.code(200).send({ success: true, message: 'OTP verified successfully.' });
      } else {
         // Use a standard error response for failed verification
        throw fastify.httpErrors.badRequest('Invalid or expired OTP.');
      }
    } catch (error) {
       // Handle potential errors during verification (e.g., store connection issues)
       fastify.log.error({ err: error, phoneNumber }, `Error during OTP verification for ${phoneNumber}`);
       // Re-throw specific known errors or a generic one
       if (error.message?.includes('unavailable')) { // Check for our Redis unavailable errors
           throw fastify.httpErrors.serviceUnavailable('OTP service temporarily unavailable.');
       }
       if (error.statusCode) { // If it's already an HttpError from verifyOtp or elsewhere
           throw error;
       }
       throw fastify.httpErrors.internalServerError('Failed to verify OTP.');
    }
  });
}

export default otpRoutes;
  • Schemas: Corrected regex patterns for phoneNumber and otp. Added a note about using process.env for OTP_LENGTH in the schema due to limitations in accessing fastify.config during schema definition.
  • Route Logic:
    • Imports both memory and Redis service functions.
    • Determines which storage functions (storeOtp, verifyOtp) to use based on whether fastify.redis is available after app setup.
    • Adds checks to ensure Redis is available if it was configured via REDIS_URL, returning 503 if not.
    • Calls the selected storage functions (storeOtp/verifyOtp).
    • Includes robust try...catch blocks, using fastify.httpErrors for standard responses.
    • Applies route-specific rate limiting using config: { rateLimit: rateLimitOptions }.

5. Integrating Plugins and App Setup

Now, let's assemble the Fastify application, register plugins in the correct order, and wire up the routes.

5.1 Application Setup (src/app.js):

javascript
// src/app.js
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import env from '@fastify/env';
import rateLimit from '@fastify/rate-limit';
import redisPlugin from '@fastify/redis'; // Renamed import for clarity
import awsSns from 'fastify-aws-sns';
import environmentSchema from './config/environment.js';
import otpRoutes from './routes/otp.js';

async function buildApp(options = {}) {
  const fastify = Fastify({
    logger: {
        level: process.env.LOG_LEVEL || 'info', // Default level before config loaded
        transport: process.env.NODE_ENV !== 'production'
            ? { target: 'pino-pretty' } // Pretty print logs in development
            : undefined,
    },
    ...options // Allow passing test-specific options
  });

  // 1. Register @fastify/env to load and validate environment variables
  await fastify.register(env, {
    dotenv: true, // Load .env file
    schema: environmentSchema,
    confKey: 'config' // Access variables via fastify.config
  });
  // Now fastify.config is available
  fastify.log.level = fastify.config.LOG_LEVEL; // Set final log level
  fastify.log.info(`Log level set to: ${fastify.config.LOG_LEVEL}`);

  // 2. Register @fastify/sensible for practical defaults (HTTP errors, etc.)
  await fastify.register(sensible);

  // 3. Register @fastify/redis *conditionally* BEFORE rate-limit if needed
  // This makes fastify.redis available if connection succeeds.
  let redisClient = null;
  if (fastify.config.REDIS_URL) {
    try {
      await fastify.register(redisPlugin, {
        url: fastify.config.REDIS_URL,
        closeClient: true // Close client when Fastify shuts down
        // Add other ioredis options here if needed
      });
      redisClient = fastify.redis; // Get the client instance
      fastify.log.info(`Redis connection registered: ${fastify.config.REDIS_URL}`);
    } catch (err) {
      fastify.log.error({ err }, 'Failed to connect to Redis. Rate limiting and OTP storage will use in-memory fallback.');
      // redisClient remains null
    }
  } else {
    fastify.log.warn('REDIS_URL not configured. Using in-memory OTP storage and rate limiting.');
  }

  // 4. Register @fastify/rate-limit *ONCE*, conditionally providing the Redis client
  // This registration happens AFTER @fastify/redis attempt.
  const rateLimitConfig = {
      global: false, // Apply limits per-route
      ...(redisClient && { redis: redisClient }) // Conditionally add redis client to config
  };
  await fastify.register(rateLimit, rateLimitConfig);
  if (redisClient) {
      fastify.log.info('Rate limiter configured to use Redis store.');
  } else {
      fastify.log.info('Rate limiter configured to use in-memory store.');
  }

  // 5. Register fastify-aws-sns plugin
  // It automatically picks up credentials from environment variables
  await fastify.register(awsSns);
  fastify.log.info('AWS SNS plugin registered. Using credentials from environment variables.');

  // 6. Register API Routes
  await fastify.register(otpRoutes, { prefix: '/api/v1' }); // Prefix routes with /api/v1

  // Graceful shutdown handler
  fastify.addHook('onClose', (instance, done) => {
    instance.log.info('Server closing. Closing connections...');
    // @fastify/redis handles its own closing via closeClient: true
    // Add other cleanup if needed
    done();
  });

  return fastify;
}

export default buildApp;
  • Logging: Configures Pino logger, using pino-pretty (dev dependency). Log level set from fastify.config.
  • Plugin Registration Order: Critical for dependencies.
    1. @fastify/env: Loads config first.
    2. @fastify/sensible: Adds utilities.
    3. @fastify/redis: Conditionally registered based on REDIS_URL. If successful, fastify.redis becomes available. Errors are caught.
    4. @fastify/rate-limit: Registered once. The configuration object passed during registration conditionally includes the redis client obtained from the previous step. This ensures rate-limit uses Redis if available, otherwise defaults to memory store.
    5. fastify-aws-sns: Registered to enable fastify.snsSMS.
    6. otpRoutes: Registers API endpoints.
  • Graceful Shutdown: onClose hook logs shutdown. @fastify/redis handles its own connection closing.

5.2 Server Entry Point (src/server.js):

javascript
// src/server.js
import buildApp from './app.js';

async function start() {
  let app;
  try {
    // Build the app instance, environment variables are loaded within buildApp
    app = await buildApp();

    // Access config AFTER buildApp has run and registered @fastify/env
    const host = app.config.HOST;
    const port = app.config.PORT;

    // Start listening
    await app.listen({ port: parseInt(port, 10), host: host });

    // Log server start AFTER listen() is successful and logger is fully configured
    app.log.info(`Server listening on http://${host}:${port}`);
    app.log.info(`Using AWS Region: ${app.config.AWS_DEFAULT_REGION}`);
    if (app.redis) { // Check if redis client was successfully attached
        app.log.info(`Successfully connected to Redis for OTP Store & Rate Limiting: ${app.config.REDIS_URL}`);
    } else if (app.config.REDIS_URL) { // Redis was configured but failed
         app.log.warn(`Redis connection FAILED (${app.config.REDIS_URL}). Using in-memory storage/rate-limiting.`);
    } else { // Redis was not configured
        app.log.warn(`Redis not configured. Using in-memory storage/rate-limiting (NOT FOR PRODUCTION).`);
    }

  } catch (err) {
    // Log the error even if the full logger wasn't initialized
    console.error('Error starting server:', err);
    if (app && app.log) { // Check if logger exists
        app.log.fatal({ err }, 'Failed to start server');
    }
    process.exit(1);
  }

  // Handle Terminate signals for graceful shutdown
    const K_TERMINATE_SIGNALS = ['SIGINT', 'SIGTERM'];
    K_TERMINATE_SIGNALS.forEach(signal => {
        process.on(signal, async () => {
            try {
                console.log(`Received ${signal}. Shutting down gracefully...`);
                if (app) {
                    await app.close(); // Trigger Fastify's onClose hooks
                    console.log('Server closed.');
                }
                process.exit(0);
            } catch (err) {
                console.error('Error during graceful shutdown:', err);
                if (app && app.log) {
                    app.log.error({ err }, 'Error during shutdown.');
                }
                process.exit(1);
            }
        });
    });
}

start();
  • Imports buildApp.
  • Retrieves HOST and PORT from app.config.
  • Starts the server using app.listen().
  • Includes startup error handling.
  • Logs Redis status more accurately based on whether app.redis exists.
  • Implements signal handlers (SIGINT, SIGTERM) for graceful shutdown via app.close().

5.3 Update package.json Scripts:

Ensure your package.json includes scripts for starting, development, and testing:

json
// package.json (scripts section excerpt)
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js | pino-pretty",
    "test": "tap \"test/**/*.test.js\""
  },
  • start: Runs the application normally.
  • dev: Runs with Node's watcher and pretty-printed logs (requires pino-pretty installed as a dev dependency).
  • test: Runs tests using tap (requires tap installed as a dev dependency).

The in-memory store is unsuitable for production. Let's implement the Redis storage logic in otpService.

6.1 Implement Redis Functions in otpService.js:

Fill in the placeholder functions in src/services/otpService.js.

javascript
// src/services/otpService.js
import crypto from 'node:crypto';

// --- In-Memory Store (For fallback/comparison - see Section 3) ---
// ... (previous in-memory code remains here) ...

Frequently Asked Questions

How to generate secure OTP codes in Node.js?

Use Node.js's built-in `crypto` module's `crypto.randomInt()` method to generate cryptographically secure random numbers for OTPs, ensuring sufficient length (e.g., 6 digits). The article provides a `generateOtp` function demonstrating this best practice.

What is rate limiting and why is it important for OTP endpoints?

Rate limiting protects against brute-force attacks by limiting the number of requests from a single IP address within a specific time window. The article uses `@fastify/rate-limit` to safeguard OTP endpoints. If REDIS_URL is provided then redis will be used for rate limiting, otherwise an in-memory store will be used which is not suitable for production and is prone to errors

How to set up environment variables in Fastify?

Use the `@fastify/env` plugin to load and validate environment variables. Create a schema defining variable types and defaults. Load a `.env` file for local development and set system environment variables in production, as described in the article's setup steps.

What is the purpose of @fastify/sensible?

The `@fastify/sensible` plugin provides sensible defaults for Fastify applications, including standard HTTP error handling, simplifying error management. It allows you to avoid explicitly handling every potential error condition within your route logic.

How to integrate Redis into a Fastify app for OTP storage?

Use the `@fastify/redis` plugin to integrate a Redis server for storing OTPs. Provide the `REDIS_URL` environment variable and the plugin will handle the connection. The article demonstrates implementing Redis-based `storeOtp` and `verifyOtp` functions and using them conditionally.

How does the system architecture of the OTP/2FA system work?

A user requests an OTP via a Fastify server, which generates and stores the OTP using Redis or an in-memory store (for development). The server then uses AWS SNS to send the OTP to the user's phone via SMS. The user submits the OTP to the server, which verifies it against the stored value.

How to structure a Fastify project with proper separation of concerns?

The article recommends a structure with directories for routes, services, config, and tests. This approach separates API definitions, business logic, environment settings, and automated tests, promoting maintainability and testability.

Why is Node.js and Fastify a good choice for implementing OTP?

Node.js's non-blocking nature handles concurrent OTP requests efficiently, while Fastify provides a fast and extensible framework for building the API layer. This combination is well-suited for real-time OTP generation and verification.

What AWS credentials are needed for sending SMS messages?

You need an AWS IAM user with, at minimum, the `sns:Publish` permission or `AmazonSNSFullAccess` policy (though less secure). Access Key ID and Secret Access Key for this IAM user are required to interact with the SNS service and are stored in a local `.env` file.

What are the prerequisites for following this tutorial?

You need Node.js and npm installed, an AWS account with IAM and SNS permissions, basic understanding of JavaScript and Fastify, a terminal, and optionally a Redis server for production OTP storage.

How to implement 2FA with Fastify and AWS SNS?

Implement 2FA by first setting up a Fastify project with required dependencies like `fastify-aws-sns`, then configuring an AWS IAM user with SNS permissions. Next, create an OTP service to generate, store, and verify OTPs, using Redis for production. Finally, define Fastify routes to request and verify OTPs, integrating rate limiting and error handling.

What is fastify-aws-sns plugin used for?

The `fastify-aws-sns` plugin simplifies interaction with the AWS Simple Notification Service (SNS) API within a Fastify application. It streamlines the process of sending SMS messages containing OTP codes to users for two-factor authentication.

Why use Redis for OTP storage in production?

Redis is recommended for production OTP storage due to its persistence, performance, and ability to handle TTL efficiently. In-memory storage, while simpler for development, lacks persistence and is not reliable in a production setting where server restarts can occur.

When should I use Transactional vs. Promotional SMS type?

Use "Transactional" for OTP messages as it's optimized for high reliability and delivery speed. "Promotional" is a lower-cost option, but not ideal for time-sensitive verification codes. The article recommends defaulting to Transactional and shows how to set this via environment variables or AWS account defaults.

Can I customize the SMS sender ID with AWS SNS?

Yes, you can customize the SMS sender ID, but regional support and regulations vary. The article advises using the `SNS_SMS_SENDER_ID` environment variable, which provides per-application flexibility, along with appropriate configuration within the AWS console to establish default sender IDs or override custom ones.