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.
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
Define the expected environment variables in src/config/environment.js:
JavaScript
// src/config/environment.jsconst 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 defaultOTP_TTL_SECONDS: { type: 'integer', default: 300 }, // 5 minutes, removed from required as it has a defaultREDIS_URL: { type: 'string' }, // e.g., 'redis://localhost:6379'LOG_LEVEL: { type: 'string', default: 'info'}, // trace, debug, info, warn, error, fatalRATE_LIMIT_MAX_REQUESTS: { type: 'integer', default: 5 }, // Max requests per windowRATE_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'
}
};
exportdefault environmentSchema;
1.6 Set up .env.example:
Populate .env.example as a template for other developers (and yourself):
.env
# .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.
Enter a User name (e.g., fastify-sns-otp-service).
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.
Click Next.
Choose Attach policies directly.
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.
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.
Click Next.
Review the user details and permissions, then click Create user.
2.2 Obtain Access Keys:
After the user is created, click on the username in the user list.
Go to the Security credentials tab.
Scroll down to Access keys and click Create access key.
Select Application running outside AWS (or Command Line Interface (CLI) if applicable).
Understand the recommendation (alternatives exist, but access keys are needed here). Click Next.
Optionally add a description tag (e.g., fastify-sns-otp-app).
Click Create access key.
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.
In the left navigation pane, choose Text messaging (SMS).
Under Account spending limit, click Edit and set a reasonable monthly limit to prevent unexpected costs (e.g., $1.00 initially). Save changes.
Under Default settings for text messages, click Edit.
Set the Default message type to Transactional for OTP messages (optimized for reliability).
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.
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.jsimport crypto from'node:crypto';
// --- In-Memory Store (Simple Example - Replace with Redis in Section 6) ---const otpStore = newMap();
/**
* 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.
*/asyncfunctionstoreOtpMemory(fastify, phoneNumber, otp, ttlSeconds) {
const expiry = Date.now() + ttlSeconds * 1000;
otpStore.set(phoneNumber, { otp, expiry });
// Simple in-memory cleanup - Prone to issues if server restartssetTimeout(() => {
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.
*/asyncfunctionverifyOtpMemory(fastify, phoneNumber, submittedOtp) {
const storedData = otpStore.get(phoneNumber);
if (!storedData) {
fastify.log.warn(`Verification failed: No OTP found in memory for ${phoneNumber}.`);
returnfalse; // 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 OTPreturnfalse; // 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 reusereturntrue; // OTP matches
}
fastify.log.warn(`Verification failed: Incorrect OTP for ${phoneNumber}.`);
returnfalse; // 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.
*/functiongenerateOtp(length) {
// Ensure length is positive integerconst otpLength = Math.max(1, Math.floor(length));
// Calculate the minimum and maximum values for the OTPconst min = Math.pow(10, otpLength - 1);
const max = Math.pow(10, otpLength) - 1;
// Generate a cryptographically secure random number within the rangereturn (crypto.randomInt(min, max + 1)).toString().padStart(otpLength, '0');
}
// --- Redis-based Store (See Section 6 for implementation) ---// Placeholder functions, will be implemented in Section 6asyncfunctionstoreOtpRedis(fastify, phoneNumber, otp, ttlSeconds) {
thrownewError('storeOtpRedis not implemented yet');
}
asyncfunctionverifyOtpRedis(fastify, phoneNumber, submittedOtp) {
thrownewError('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 setupexport {
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.jsimport {
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 responsetype: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' }
}
}
// Fastify automatically handles 4xx/5xx based on @fastify/sensible
}
};
// --- End Schemas ---asyncfunctionotpRoutes(fastify, options) {
// Apply rate limiting specifically to OTP routesconst 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 availabilityconst 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 unavailableif (!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 pluginconst 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 methodawaitstoreOtp(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 errorsif (error.message?.includes('unavailable')) { // Check for our Redis unavailable errorsthrow fastify.httpErrors.serviceUnavailable('OTP service temporarily unavailable.');
}
if (error.code === 'InvalidParameterException') {
// Often due to invalid phone number format for SNS regionthrow 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 errorsthrow 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 unavailableif (!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 methodconst isValid = awaitverifyOtp(fastify, phoneNumber, otp);
if (isValid) {
return reply.code(200).send({ success: true, message: 'OTP verified successfully.' });
} else {
// Use a standard error response for failed verificationthrow 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 oneif (error.message?.includes('unavailable')) { // Check for our Redis unavailable errorsthrow fastify.httpErrors.serviceUnavailable('OTP service temporarily unavailable.');
}
if (error.statusCode) { // If it's already an HttpError from verifyOtp or elsewherethrow error;
}
throw fastify.httpErrors.internalServerError('Failed to verify OTP.');
}
});
}
exportdefault 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.jsimportFastifyfrom'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 clarityimport awsSns from'fastify-aws-sns';
import environmentSchema from'./config/environment.js';
import otpRoutes from'./routes/otp.js';
asyncfunctionbuildApp(options = {}) {
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info', // Default level before config loadedtransport: 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 variablesawait fastify.register(env, {
dotenv: true, // Load .env fileschema: 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 variablesawait fastify.register(awsSns);
fastify.log.info('AWS SNS plugin registered. Using credentials from environment variables.');
// 6. Register API Routesawait 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 neededdone();
});
return fastify;
}
exportdefault buildApp;
Logging: Configures Pino logger, using pino-pretty (dev dependency). Log level set from fastify.config.
Plugin Registration Order: Critical for dependencies.
@fastify/env: Loads config first.
@fastify/sensible: Adds utilities.
@fastify/redis: Conditionally registered based on REDIS_URL. If successful, fastify.redis becomes available. Errors are caught.
@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.
fastify-aws-sns: Registered to enable fastify.snsSMS.
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.jsimport buildApp from'./app.js';
asyncfunctionstart() {
let app;
try {
// Build the app instance, environment variables are loaded within buildApp
app = awaitbuildApp();
// Access config AFTER buildApp has run and registered @fastify/envconst host = app.config.HOST;
const port = app.config.PORT;
// Start listeningawait 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}`);
} elseif (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 initializedconsole.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 shutdownconstK_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 hooksconsole.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:
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
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:
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:
mkdir fastify-sns-otp cd fastify-sns-otp npm init -yThis creates a
package.jsonfile.1.2 Install Dependencies:
Install Fastify and essential plugins:
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 integrateioredis.Install development dependencies (like
pino-prettyfor logging):npm install --save-dev pino-pretty tap sinon # tap & sinon for testing1.3 Project Structure:
Create the basic directory structure:
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 .gitignoresrc/: 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
.gitignorefile to avoid committing sensitive data and unnecessary files:1.5 Configure Environment Variables Schema:
Define the expected environment variables in
src/config/environment.js:// 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.exampleas a template for other developers (and yourself):1.7 Create
.envfile:Copy
.env.exampleto.envand fill in your actual AWS credentials and any other desired settings. Remember to keep this file secure and out of version control.cp .env.example .env # Now edit .env with your real credentials2. AWS SNS Configuration
We need an IAM user with specific permissions to send SMS messages via SNS.
2.1 Create an IAM User:
fastify-sns-otp-service).AmazonSNSFullAccesspolicy for simplicity. Security Best Practice: For production, create a custom inline policy granting only thesns:Publishpermission to minimize attack surface.{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": ""sns:Publish"", ""Resource"": ""*"" } ] }Resourcefurther to specific Topic ARNs if using topics, but for direct SMS publishing,*is often necessary unless specific controls are in place.2.2 Obtain Access Keys:
fastify-sns-otp-app)..envfile with these keys:2.3 Configure SNS Defaults (Optional but Recommended):
While
fastify-aws-snsallows setting SMS type per message, you can set account-wide defaults in the SNS console for consistency.Transactionalfor OTP messages (optimized for reliability).SNS_SMS_SENDER_IDenvironment variable allows more flexibility per application.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):// 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'scryptomodule.storeOtpMemory/verifyOtpMemory: Initial implementation using an in-memory Map withsetTimeoutfor TTL. Marked clearly as non-production.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):// 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;phoneNumberandotp. Added a note about usingprocess.envforOTP_LENGTHin the schema due to limitations in accessingfastify.configduring schema definition.storeOtp,verifyOtp) to use based on whetherfastify.redisis available after app setup.REDIS_URL, returning 503 if not.storeOtp/verifyOtp).try...catchblocks, usingfastify.httpErrorsfor standard responses.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):// 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;pino-pretty(dev dependency). Log level set fromfastify.config.@fastify/env: Loads config first.@fastify/sensible: Adds utilities.@fastify/redis: Conditionally registered based onREDIS_URL. If successful,fastify.redisbecomes available. Errors are caught.@fastify/rate-limit: Registered once. The configuration object passed during registration conditionally includes theredisclient obtained from the previous step. This ensures rate-limit uses Redis if available, otherwise defaults to memory store.fastify-aws-sns: Registered to enablefastify.snsSMS.otpRoutes: Registers API endpoints.onClosehook logs shutdown.@fastify/redishandles its own connection closing.5.2 Server Entry Point (
src/server.js):// 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();buildApp.HOSTandPORTfromapp.config.app.listen().app.redisexists.SIGINT,SIGTERM) for graceful shutdown viaapp.close().5.3 Update
package.jsonScripts:Ensure your
package.jsonincludes scripts for starting, development, and testing:// 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 (requirespino-prettyinstalled as a dev dependency).test: Runs tests usingtap(requirestapinstalled as a dev dependency).6. Enhancing OTP Storage with Redis (Recommended)
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.// src/services/otpService.js import crypto from 'node:crypto'; // --- In-Memory Store (For fallback/comparison - see Section 3) --- // ... (previous in-memory code remains here) ...