code examples
code examples
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:
mkdir fastify-sns-otp
cd fastify-sns-otp
npm init -yThis creates a package.json file.
1.2 Install Dependencies:
Install Fastify and essential plugins:
npm install fastify fastify-aws-sns @fastify/env @fastify/sensible @fastify/rate-limit ioredis @fastify/redisfastify: 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-pretty for 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 .gitignore file to avoid committing sensitive data and unnecessary files:
# 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.db1.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.example as a template for other developers (and yourself):
# .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:63791.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.
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:
- Navigate to the IAM console in your AWS account: https://console.aws.amazon.com/iam/
- Go to Users and click Create user.
- 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
AmazonSNSFullAccesspolicy for simplicity. Security Best Practice: For production, create a custom inline policy granting only thesns:Publishpermission to minimize attack surface.- Example Custom Policy (Least Privilege):
Note: You could restrict thejson
{ ""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.
- Example Custom Policy (Least Privilege):
- 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.
- Update your
.envfile 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.
- Navigate to the SNS console: https://console.aws.amazon.com/sns/
- 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
Transactionalfor 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_IDenvironment 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):
// 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.- 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):
// 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
phoneNumberandotp. Added a note about usingprocess.envforOTP_LENGTHin the schema due to limitations in accessingfastify.configduring schema definition. - Route Logic:
- Imports both memory and Redis service functions.
- Determines which storage functions (
storeOtp,verifyOtp) to use based on whetherfastify.redisis 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...catchblocks, usingfastify.httpErrorsfor 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):
// 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 fromfastify.config. - Plugin Registration Order: Critical for dependencies.
@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.
- Graceful Shutdown:
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();- Imports
buildApp. - Retrieves
HOSTandPORTfromapp.config. - Starts the server using
app.listen(). - Includes startup error handling.
- Logs Redis status more accurately based on whether
app.redisexists. - Implements signal handlers (
SIGINT,SIGTERM) for graceful shutdown viaapp.close().
5.3 Update package.json Scripts:
Ensure your package.json includes 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) ...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.