code examples
code examples
Node.js Fastify OTP/2FA with Plivo SMS: Complete Implementation Guide 2025
Build production-ready SMS OTP authentication with Node.js, Fastify, Plivo, and Redis. Includes security best practices, OWASP guidelines, code examples, and deployment.
Implementing Node.js Fastify OTP/2FA with Plivo
Two-factor authentication (2FA) adds security to applications by requiring users to provide a second verification form beyond a password. This guide uses one-time passwords (OTPs) sent via SMS.
Build a production-ready OTP verification system using Node.js with Fastify and Plivo's SMS API. This guide covers project setup, deployment, testing, security, error handling, and reliability best practices.
⚠️ Security Advisory: SMS OTP Vulnerabilities
Important: While SMS-based OTP is widely deployed and functional, OWASP security guidelines (2025) and NIST (National Institute of Standards and Technology) identify SMS OTP as less secure than alternative 2FA methods due to known vulnerabilities:
- SIM Swapping: Attackers convince mobile carriers to transfer a target's phone number to an attacker-controlled SIM card, intercepting all SMS messages. The FBI reported over $72 million stolen via SIM swap schemes in 2022.
- SS7 Protocol Exploits: The Signaling System 7 (SS7) protocol used by mobile networks has security flaws allowing attackers to redirect SMS messages to their own devices.
- Phishing & Smishing: Fraudsters send fake SMS messages impersonating legitimate services to trick users into revealing OTPs on malicious websites.
- Man-in-the-Middle Attacks: Attackers can intercept SMS messages through cell tower emulation or malware on mobile devices.
NIST Guidance: NIST's digital identity guidelines have discouraged SMS-based authentication since 2016, considering it insecure and easily exploitable for targeted attacks.
Recommended Alternatives: For high-security applications, consider implementing:
- FIDO2/Passkeys: Phishing-resistant authentication using public-key cryptography and biometrics
- TOTP Authenticator Apps: Google Authenticator, Authy, or similar apps that generate codes locally without network transmission
When to Use SMS OTP: SMS OTP remains acceptable for low-to-medium risk applications where convenience is prioritized, or as a fallback method alongside more secure primary 2FA options. Any MFA is better than no MFA.
Citation1. From source: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html, Title: OWASP Multifactor Authentication Cheat Sheet, Text: While the disadvantages and weaknesses of various different types of MFA are only relevant against targeted attacks, any MFA is better than no MFA. NIST's digital identity guidelines have discouraged SMS-based authentication since 2016.
Project Overview and Goals
Goal: Build a secure and reliable SMS-based OTP verification API using Node.js, Fastify, Redis, and Plivo.
Problem Solved: Protects user accounts from unauthorized access through 2FA, even if passwords are compromised.
Technologies:
- Node.js: JavaScript runtime for server-side applications.
- Fastify: High-performance web framework for Node.js. Chosen for speed, extensibility, and built-in schema validation.
- Plivo: Cloud communications platform providing SMS API services. Chosen for reliable message delivery and straightforward API.
- Redis: In-memory data store for temporarily storing OTPs with automatic expiration. Chosen for speed and suitability for caching.
otp-generator: Library for generating cryptographically secure OTPs.dotenv/@fastify/env: For managing environment variables securely.
Architecture:
sequenceDiagram
participant User
participant Fastify API
participant Redis
participant Plivo API
User->>+Fastify API: POST /request-otp (phoneNumber)
Fastify API->>Fastify API: Generate secure OTP
Fastify API->>+Redis: Store OTP with TTL (phoneNumber, otp)
Redis-->>-Fastify API: Confirmation
Fastify API->>+Plivo API: Send SMS (to: phoneNumber, text: otp)
Plivo API-->>-Fastify API: SMS Sent Confirmation / Error
Fastify API-->>-User: { success: true } / Error
User->>+Fastify API: POST /verify-otp (phoneNumber, otp)
Fastify API->>+Redis: Get OTP for phoneNumber
Redis-->>-Fastify API: Stored OTP / Not Found
alt OTP Found and Matches
Fastify API->>Fastify API: Compare user OTP with stored OTP
Fastify API->>+Redis: Delete OTP (optional, or let TTL expire)
Redis-->>-Fastify API: Confirmation
Fastify API-->>-User: { success: true, message: ""Verification successful"" }
else OTP Not Found or Mismatch
Fastify API-->>-User: { success: false, message: ""Invalid or expired OTP"" }
endOutcome: Functional API with two endpoints: /request-otp to send an OTP to a user's phone and /verify-otp to validate the OTP entered by the user.
Prerequisites:
- Node.js and npm (or yarn) installed
- Terminal or command prompt access
- Plivo account with API credentials (Auth ID, Auth Token) and SMS-enabled phone number
- Redis server running (locally or accessible via network)
- Basic understanding of Node.js, APIs, and asynchronous programming
1. How to Set Up Node.js Fastify OTP Project
Initialize the Node.js project, install dependencies, and set up the basic structure.
1. Initialize Project
mkdir fastify-plivo-otp
cd fastify-plivo-otp
npm init -y2. Install Dependencies:
fastify: Core web frameworkplivo-node: Plivo Node.js SDKredis: Node.js client for Redisotp-generator: Generates secure OTPs@fastify/env: Loads and validates environment variablespino-pretty: (Dev dependency) Human-readable logs during development
Note on Redis Client Version: This guide uses the redis package (v4+ API) which uses promises instead of callbacks. Redis v5 is now available, but the v4 API remains compatible. If migrating from Redis v3, note that legacy mode (createClient({ legacyMode: true })) is available but not recommended for new projects.
Citation2. From source: https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md, Title: Redis Node.js Client v3 to v4 Migration Guide, Text: Legacy mode was introduced in v4 to preserve backwards compatibility while getting access to the updated experience. The modern promise-based API is recommended for all new projects.
# Production Dependencies
npm install fastify plivo-node redis otp-generator @fastify/env
# Development Dependency
npm install --save-dev pino-pretty3. Project Structure
Create the following directories and files:
fastify-plivo-otp/
├── node_modules/
├── src/
│ ├── config/
│ │ └── index.js # Environment variable loading/validation
│ ├── routes/
│ │ └── otp.js # API routes for OTP
│ ├── services/
│ │ ├── otpService.js # Logic for OTP generation, storage, verification
│ │ └── plivoService.js # Logic for interacting with Plivo API
│ └── app.js # Fastify server setup and plugin registration
├── .env # Environment variables (DO NOT COMMIT)
├── .env.example # Example environment variables (Commit this)
├── .gitignore # Git ignore file
└── package.json4. Configure .gitignore:
Create a .gitignore file in the root directory to prevent sensitive information and unnecessary files from being committed:
# .gitignore
node_modules/
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*5. Set up Environment Variables:
Create .env.example with the required variable names:
# .env.example
NODE_ENV=development
PORT=3000
# Plivo Credentials
PLIVO_AUTH_ID=
PLIVO_AUTH_TOKEN=
PLIVO_SENDER_NUMBER=
# Redis Connection
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# REDIS_PASSWORD= # Uncomment if your Redis requires a password
# REDIS_DB=0 # Uncomment if using a specific Redis database
# OTP Settings
OTP_LENGTH=6
OTP_VALIDITY_SECONDS=300 # 5 minutesCreate a .env file (which should not be committed) and populate it with your actual Plivo credentials and Redis details. Obtain Plivo credentials from your Plivo Console under ""API"" -> ""Keys & Credentials"". Get your Plivo sender number from ""Phone Numbers"" -> ""Your Numbers"".
6. Configure Environment Loading (src/config/index.js):
We'll use @fastify/env to load and validate our environment variables.
// src/config/index.js
'use strict';
const fp = require('fastify-plugin');
const fastifyEnv = require('@fastify/env');
const schema = {
type: 'object',
required: [
'PORT',
'PLIVO_AUTH_ID',
'PLIVO_AUTH_TOKEN',
'PLIVO_SENDER_NUMBER',
'REDIS_HOST',
'REDIS_PORT',
'OTP_VALIDITY_SECONDS',
],
properties: {
NODE_ENV: { type: 'string', default: 'development' },
PORT: { type: 'number', default: 3000 },
PLIVO_AUTH_ID: { type: 'string' },
PLIVO_AUTH_TOKEN: { type: 'string' },
PLIVO_SENDER_NUMBER: { type: 'string' }, // Should be in E.164 format, e.g., +14155552671
REDIS_HOST: { type: 'string', default: '127.0.0.1' },
REDIS_PORT: { type: 'number', default: 6379 },
REDIS_PASSWORD: { type: 'string', default: null }, // Optional
REDIS_DB: { type: 'number', default: 0 }, // Optional
OTP_LENGTH: { type: 'number', default: 6 },
OTP_VALIDITY_SECONDS: { type: 'number', default: 300 },
},
};
const options = {
confKey: 'config', // Access environment variables via `fastify.config`
schema: schema,
dotenv: true, // Load .env file
};
module.exports = fp(async (fastify) => {
await fastify.register(fastifyEnv, options);
fastify.log.info('Environment variables loaded successfully.');
});Note: The fastify-plugin package is required by this configuration module. Install it if not already present:
npm install fastify-plugin7. Set up Fastify Server (src/app.js):
This file initializes the Fastify instance, registers plugins (like our config loader), sets up logging, and defines routes.
// src/app.js
'use strict';
const Fastify = require('fastify');
const configLoader = require('./config');
const otpRoutes = require('./routes/otp');
const { initializeRedis } = require('./services/otpService');
const { initializePlivo } = require('./services/plivoService');
async function buildServer() {
const fastify = Fastify({
logger: {
level: process.env.NODE_ENV === 'development' ? 'info' : 'warn',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
},
});
try {
// Register environment variable loader
await fastify.register(configLoader);
// Initialize Redis client (make it available globally in fastify instance)
const redisClient = await initializeRedis(fastify.config, fastify.log);
fastify.decorate('redis', redisClient);
fastify.log.info('Redis client initialized and decorated.');
// Initialize Plivo client
const plivoClient = initializePlivo(fastify.config, fastify.log);
fastify.decorate('plivo', plivoClient);
fastify.log.info('Plivo client initialized and decorated.');
// Register OTP routes
await fastify.register(otpRoutes, { prefix: '/api/otp' });
fastify.log.info('OTP routes registered under /api/otp.');
// Basic root route
fastify.get('/', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Graceful shutdown
const signals = ['SIGINT', 'SIGTERM'];
signals.forEach((signal) => {
process.on(signal, async () => {
fastify.log.info(`Received ${signal}, shutting down gracefully...`);
await fastify.close();
// Explicitly close Redis connection if needed, though Fastify hooks might handle it
if (fastify.redis && fastify.redis.isOpen) {
await fastify.redis.quit();
fastify.log.info('Redis connection closed.');
}
process.exit(0);
});
});
} catch (err) {
fastify.log.error(err, 'Error during server initialization');
process.exit(1);
}
return fastify;
}
module.exports = buildServer;
// Start the server if run directly (e.g., `node src/app.js`)
if (require.main === module) {
(async () => {
const server = await buildServer();
try {
await server.listen({ port: server.config.PORT, host: '0.0.0.0' });
// Logger already announces the listening address
} catch (err) {
server.log.error(err, 'Error starting server');
process.exit(1);
}
})();
}8. Add Start Script to package.json:
// package.json (add/modify scripts section)
""scripts"": {
""start"": ""node src/app.js"",
""dev"": ""NODE_ENV=development node src/app.js"",
""test"": ""echo \""Error: no test specified\"" && exit 1""
},At this point, you have the basic project structure, dependencies, configuration loading, and a Fastify server ready. Running npm run dev (after setting up .env) should start the server, although the OTP routes won't work yet.
2. How to Implement OTP Generation and Verification with Redis
Now, let's implement the core logic for generating, storing, and verifying OTPs using Redis.
1. Initialize Redis Client and OTP Functions (src/services/otpService.js):
// src/services/otpService.js
'use strict';
const redis = require('redis');
const otpGenerator = require('otp-generator');
let redisClient;
let config;
let log;
// Function to initialize and connect the Redis client
async function initializeRedis(appConfig, logger) {
if (redisClient && redisClient.isOpen) {
logger.warn('Redis client already initialized.');
return redisClient;
}
config = appConfig; // Store config for later use
log = logger; // Store logger
const redisOptions = {
socket: {
host: config.REDIS_HOST,
port: config.REDIS_PORT,
},
// Uncomment and set password if needed
// password: config.REDIS_PASSWORD,
database: config.REDIS_DB, // Default is 0
};
if (config.REDIS_PASSWORD) {
redisOptions.password = config.REDIS_PASSWORD;
}
log.info(`Attempting to connect to Redis at ${config.REDIS_HOST}:${config.REDIS_PORT}, DB: ${config.REDIS_DB}`);
redisClient = redis.createClient(redisOptions);
redisClient.on('error', (err) => log.error('Redis Client Error', err));
redisClient.on('connect', () => log.info('Redis client connected'));
redisClient.on('reconnecting', () => log.warn('Redis client reconnecting'));
redisClient.on('end', () => log.info('Redis client connection closed'));
try {
await redisClient.connect();
log.info('Successfully connected to Redis.');
} catch (err) {
log.error('Failed to connect to Redis:', err);
throw err; // Propagate error to stop server initialization if needed
}
return redisClient;
}
// Function to generate a secure OTP
function generateOtp() {
return otpGenerator.generate(config.OTP_LENGTH, {
upperCaseAlphabets: false,
lowerCaseAlphabets: false,
specialChars: false,
});
}
// Function to store OTP in Redis with expiry
// Key format: otp:<phoneNumber>
async function storeOtp(phoneNumber, otp) {
if (!redisClient || !redisClient.isOpen) {
log.error('Redis client not connected. Cannot store OTP.');
throw new Error('Internal Server Error: Database connection issue');
}
const key = `otp:${phoneNumber}`;
const validitySeconds = config.OTP_VALIDITY_SECONDS;
try {
await redisClient.setEx(key, validitySeconds, otp);
log.info(`OTP for ${phoneNumber} stored successfully. TTL: ${validitySeconds}s`);
return true;
} catch (err) {
log.error(`Failed to store OTP for ${phoneNumber}:`, err);
throw new Error('Internal Server Error: Failed to store OTP');
}
}
// Function to verify OTP against the stored value in Redis
async function verifyOtp(phoneNumber, otpToCheck) {
if (!redisClient || !redisClient.isOpen) {
log.error('Redis client not connected. Cannot verify OTP.');
throw new Error('Internal Server Error: Database connection issue');
}
const key = `otp:${phoneNumber}`;
try {
const storedOtp = await redisClient.get(key);
if (!storedOtp) {
log.warn(`No OTP found for ${phoneNumber} or it has expired.`);
return false;
}
if (storedOtp === otpToCheck) {
log.info(`OTP verification successful for ${phoneNumber}.`);
// Optionally delete the key immediately after successful verification
// await redisClient.del(key);
// log.info(`OTP key ${key} deleted after successful verification.`);
return true;
} else {
log.warn(`OTP mismatch for ${phoneNumber}.`);
// Implement rate limiting or attempt tracking here if needed
return false;
}
} catch (err) {
log.error(`Failed to verify OTP for ${phoneNumber}:`, err);
throw new Error('Internal Server Error: Failed to verify OTP');
}
}
module.exports = {
initializeRedis,
generateOtp,
storeOtp,
verifyOtp,
// Export client only if absolutely necessary outside this module
// getRedisClient: () => redisClient,
};Why Redis? Redis is used here because it's incredibly fast for the simple task of storing a short string (the OTP) associated with a key (the phone number) and automatically deleting it after a set time (Time-To-Live or TTL). This avoids the need for complex database cleanup jobs.
Why otp-generator? Using Math.random() as shown in some simple examples is not cryptographically secure. otp-generator uses Node.js's crypto module, providing much stronger randomness suitable for security purposes.
Secure Alternatives: You can also generate cryptographically secure OTPs using Node.js's built-in crypto.randomBytes() or crypto.randomInt() methods directly, or use libraries like otplib for TOTP/HOTP (time-based or HMAC-based one-time passwords). For production systems requiring higher security, consider implementing TOTP algorithms compatible with authenticator apps.
Citation4. From source: https://dev.to/mahendra_singh_7500/generating-a-secure-6-digit-otp-in-javascript-and-nodejs-2nbo, Title: Generating a Secure 6-Digit OTP in JavaScript and Node.js, Text: Math.random() does not provide cryptographically secure random numbers. Use the crypto module's randomBytes() for security-related purposes. The crypto module ensures OTPs are cryptographically secure.
3. Building the API Layer
Define the API endpoints using Fastify routes and schemas for validation.
1. Create OTP Routes (src/routes/otp.js):
// src/routes/otp.js
'use strict';
const { generateOtp, storeOtp, verifyOtp } = require('../services/otpService');
const { sendOtpSms } = require('../services/plivoService');
// Schema for request body validation (Request OTP)
const requestOtpSchema = {
body: {
type: 'object',
required: ['phoneNumber'],
properties: {
phoneNumber: {
type: 'string',
// Basic pattern for E.164 format (starts with +, followed by digits)
pattern: '^\\+[1-9]\\d{1,14}',
description: 'User phone number in E.164 format (e.g., +14155552671)',
},
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
// Avoid sending OTP back in response for security
},
},
// Define other responses (400, 500) if needed
},
};
// Route registration function
async function otpRoutes(fastify, options) {
// Get OTP length from validated config
const otpLength = fastify.config.OTP_LENGTH;
// Define schema for Verify OTP *inside* the plugin function
// This ensures fastify.config is available
const verifyOtpSchema = {
body: {
type: 'object',
required: ['phoneNumber', 'otp'],
properties: {
phoneNumber: {
type: 'string',
pattern: '^\\+[1-9]\\d{1,14}',
description: 'User phone number in E.164 format',
},
otp: {
type: 'string',
// Use the OTP_LENGTH from config for the pattern
pattern: `^[0-9]{${otpLength}}$`,
description: `The ${otpLength}-digit OTP received via SMS`,
},
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
// Define other responses (400, 401, 500) if needed
},
};
// Endpoint to request an OTP
fastify.post('/request', { schema: requestOtpSchema }, async (request, reply) => {
const { phoneNumber } = request.body;
const log = request.log; // Use request-specific logger
try {
const otp = generateOtp();
log.info(`Generated OTP ${otp} for ${phoneNumber}`); // Log OTP in dev only if necessary
// Store OTP in Redis first
await storeOtp(phoneNumber, otp); // This will throw if Redis fails
// Then, attempt to send via Plivo
const messageSid = await sendOtpSms(fastify.plivo, fastify.config, log, phoneNumber, otp);
log.info(`OTP SMS initiated for ${phoneNumber}. Message SID: ${messageSid}`);
return reply.code(200).send({ success: true, message: 'OTP sent successfully.' });
} catch (error) {
log.error(`Error requesting OTP for ${phoneNumber}: ${error.message}`);
// Differentiate between Plivo errors and Redis errors if needed
if (error.message.includes('Plivo') || error.message.includes('SMS')) {
// Consider specific error codes/messages from Plivo if available
return reply.code(500).send({ success: false, message: 'Failed to send OTP via SMS. Please try again later.' });
} else if (error.message.includes('Database') || error.message.includes('store OTP')) {
return reply.code(500).send({ success: false, message: 'Internal server error. Please try again later.' });
}
// Generic fallback
return reply.code(500).send({ success: false, message: 'An unexpected error occurred.' });
}
});
// Endpoint to verify an OTP
fastify.post('/verify', { schema: verifyOtpSchema }, async (request, reply) => {
const { phoneNumber, otp } = request.body;
const log = request.log;
try {
const isValid = await verifyOtp(phoneNumber, otp);
if (isValid) {
log.info(`OTP verification successful for ${phoneNumber}`);
// Here you would typically grant access, issue a token, etc.
return reply.code(200).send({ success: true, message: 'OTP verified successfully.' });
} else {
log.warn(`Invalid or expired OTP provided for ${phoneNumber}`);
// Return 400 Bad Request as the input OTP is invalid/expired
return reply.code(400).send({ success: false, message: 'Invalid or expired OTP.' });
}
} catch (error) {
log.error(`Error verifying OTP for ${phoneNumber}: ${error.message}`);
// Handle potential Redis errors during verification
if (error.message.includes('Database') || error.message.includes('verify OTP')) {
return reply.code(500).send({ success: false, message: 'Internal server error. Please try again later.' });
}
return reply.code(500).send({ success: false, message: 'An unexpected error occurred during verification.' });
}
});
}
module.exports = otpRoutes;Why Schemas? Fastify's built-in JSON schema validation is highly efficient. It automatically validates incoming request bodies (and can validate params, query strings, headers, and responses) before your handler code runs. This prevents invalid data from reaching your core logic, improving security and reducing boilerplate validation code. The schemas also serve as implicit documentation for your API endpoints. By defining the verifyOtpSchema inside the otpRoutes function, we ensure access to the validated fastify.config to set the correct OTP length dynamically.
4. How to Integrate Plivo SMS API for OTP Delivery
Now, let's set up the Plivo client and the function to send the SMS.
1. Initialize Plivo Client and Sending Function (src/services/plivoService.js):
// src/services/plivoService.js
'use strict';
const plivo = require('plivo');
let plivoClient;
let config;
let log;
// Function to initialize the Plivo client
function initializePlivo(appConfig, logger) {
if (plivoClient) {
logger.warn('Plivo client already initialized.');
return plivoClient;
}
config = appConfig; // Store config for use in sendOtpSms
log = logger; // Store logger
log.info('Initializing Plivo client...');
try {
// Ensure Auth ID and Token are strings
const authId = String(config.PLIVO_AUTH_ID);
const authToken = String(config.PLIVO_AUTH_TOKEN);
plivoClient = new plivo.Client(authId, authToken);
log.info('Plivo client initialized successfully.');
return plivoClient;
} catch (error) {
log.error('Failed to initialize Plivo client:', error);
throw error; // Stop server initialization if Plivo setup fails
}
}
// Function to send OTP via Plivo SMS API
async function sendOtpSms(client, appConfig, logger, destinationNumber, otp) {
// Use passed-in client, config, logger for better testability and context
const senderNumber = appConfig.PLIVO_SENDER_NUMBER;
const messageText = `Your verification code is: ${otp}`;
logger.info(`Attempting to send OTP SMS from ${senderNumber} to ${destinationNumber}`);
try {
const response = await client.messages.create(
senderNumber, // src
destinationNumber, // dst
messageText // text
);
// Check Plivo response structure. The Plivo REST API returns message_uuid (underscore),
// but the Node.js SDK (v4+) converts this to camelCase as messageUuid.
// The response is an array of message UUIDs, typically containing one element for single messages.
if (response && response.messageUuid && Array.isArray(response.messageUuid) && response.messageUuid.length > 0) {
const messageSid = response.messageUuid[0];
logger.info(`Plivo SMS sent successfully. Message UUID: ${messageSid}`);
return messageSid; // Return the message identifier
} else {
// Log the actual response structure if it deviates from expectation.
logger.warn('Plivo response structure unexpected or message UUID missing/invalid.', { plivoResponse: response });
// Consider this potentially successful but indicate logging is needed.
return 'Unknown UUID - Check Plivo Logs';
}
} catch (error) {
// Plivo SDK errors often have useful details
logger.error(`Plivo API Error sending SMS to ${destinationNumber}: ${error.message}`, {
statusCode: error.statusCode, // Plivo errors might include status codes
errorDetails: error.error // Plivo might provide more detailed error object
});
// Re-throw a more specific error or a generic one
throw new Error(`Plivo SMS sending failed: ${error.message}`);
}
}
module.exports = {
initializePlivo,
sendOtpSms,
};Obtaining Plivo Credentials:
- Log in to your Plivo Console.
- Navigate to the ""API"" section in the left-hand menu, then select ""Keys & Credentials"".
- Your Auth ID and Auth Token are displayed here. Copy these securely into your
.envfile.PLIVO_AUTH_ID=YOUR_AUTH_IDPLIVO_AUTH_TOKEN=YOUR_AUTH_TOKEN
- Navigate to ""Phone Numbers"" -> ""Your Numbers"".
- Find an SMS-enabled number you have rented. Copy the full number, including the country code (E.164 format, e.g.,
+14155551234). This is your sender number.PLIVO_SENDER_NUMBER=+14155551234
Environment Variable Explanation:
PLIVO_AUTH_ID: Your unique Plivo account identifier for API authentication.PLIVO_AUTH_TOKEN: Your secret API token for authentication. Treat this like a password.PLIVO_SENDER_NUMBER: The Plivo phone number (in E.164 format) from which the SMS OTP messages will be sent.
5. Implementing Error Handling and Logging
We've already incorporated basic error handling and logging, but let's refine it.
- Logging: We configured
pino-prettyfor readable development logs and standard JSON logs (suitable for log aggregators) in production withinsrc/app.js. Fastify automatically assigns a uniquerequest.loginstance to each request, providing context. We uselog.info,log.warn, andlog.errorappropriately in our services and routes. - Error Handling Strategy:
- Validation Errors: Fastify schemas handle bad requests (400) automatically.
- Service Errors:
otpService.jsandplivoService.jsthrow errors on critical failures (e.g., Redis connection down, Plivo API errors). - Route Handlers: The
try...catchblocks insrc/routes/otp.jscatch errors from services. They attempt to differentiate between internal errors (Redis) and external service errors (Plivo) to provide slightly more context in the 500 response, although user-facing messages remain generic for security. Verification failures (wrong/expired OTP) result in a 400 Bad Request. - Fastify Global Handler: For unhandled errors, Fastify has a default error handler. You could customize this using
fastify.setErrorHandler(async (error, request, reply) => { ... })inapp.jsfor more advanced global error logging or formatting.
Example: Adding a Custom Global Error Handler (Optional):
// src/app.js (inside buildServer, after plugin registration)
fastify.setErrorHandler(async (error, request, reply) => {
request.log.error({ err: error }, 'Unhandled error occurred!');
// Example: Hide internal details in production
if (fastify.config.NODE_ENV !== 'development' && !error.statusCode) {
// Default to 500 if no specific status code is set by the error
reply.code(500).send({ success: false, message: 'An internal server error occurred.' });
return;
}
// Use error's status code if available, otherwise default to 500
const statusCode = error.statusCode || 500;
// Ensure message is user-friendly, especially for validation errors
const message = error.validation ? 'Invalid input provided.' : (error.message || 'An error occurred.');
reply.code(statusCode).send({
success: false,
message: message,
// Optionally include error code or validation details in development
...(fastify.config.NODE_ENV === 'development' && {
code: error.code,
validation: error.validation,
stack: error.stack
})
});
});Retry Mechanisms:
Implementing retries, especially for external API calls like Plivo, can improve resilience. However, it adds complexity.
- Simple Retry: You could wrap the
sendOtpSmscall in a simple loop with delays. - Exponential Backoff: Use libraries like
async-retryorp-retryfor more robust retry logic with increasing delays between attempts. This prevents overwhelming the external service during transient outages.
For this guide, we'll omit complex retry code, but acknowledge its importance in production. Retrying Redis operations is generally less critical if the connection is stable, but could be considered. Be cautious not to retry operations that shouldn't be duplicated (like sending multiple OTPs). The request-store-send sequence helps mitigate duplicate sends if the Plivo call fails initially but a subsequent request succeeds.
6. Database Schema and Data Layer
In this specific implementation, we are intentionally not using a traditional relational database (like PostgreSQL or MySQL) for the core OTP flow.
- User Data: We assume user data (like profile information, hashed passwords) is stored elsewhere in your main application database. This OTP service focuses solely on the verification step.
- OTP Storage: Redis serves as our data layer for temporary OTP storage.
- Schema: Key-value based.
- Key:
otp:<phoneNumberInE.164Format>(e.g.,otp:+14155552671) - Value: The generated OTP string (e.g.,
""123456"") - TTL (Time-To-Live): Set via
SETEXcommand (e.g., 300 seconds). Redis automatically deletes the key after the TTL expires.
- Key:
- Data Access: Handled by
storeOtpandverifyOtpfunctions insrc/services/otpService.jsusing theredisclient library. - Migrations: Not applicable for this Redis usage pattern. Schema is implicit in the key structure.
- Performance: Redis is extremely fast for this type of workload (simple GET/SET/DEL operations). Ensure your Redis instance has sufficient memory and network bandwidth.
- Schema: Key-value based.
If you needed to persist verification attempts or link verifications to user accounts permanently, you would integrate with your main application database, potentially adding tables like otp_verifications (user_id, phone_number, timestamp, success_status). However, for the basic OTP flow, Redis is sufficient and often preferred for performance.
7. Adding Security Features
Security is paramount for authentication systems.
- Input Validation: Already implemented using Fastify's schemas (
requestOtpSchema,verifyOtpSchema) insrc/routes/otp.js. This prevents malformed requests and basic injection attempts. The E.164 phone number pattern and OTP format pattern add specific constraints. - Secure OTP Generation: Using
otp-generatorensures cryptographically secure random numbers, making OTPs hard to guess. - Secure OTP Storage: Storing OTPs temporarily in Redis with a short TTL (e.g., 5 minutes) minimizes the window of opportunity for attackers if Redis is somehow compromised. Ensure Redis is properly secured (network access, password).
- Transport Security: Always run your application behind HTTPS in production to encrypt data in transit between the user and your API, and ideally between your API and Plivo/Redis if they are on different networks.
- Rate Limiting: Crucial to prevent brute-force attacks on both requesting OTPs (SMS Pumping/Toll Fraud) and verifying OTPs.
- Implementation: Use
@fastify/rate-limit.
bashnpm install @fastify/rate-limit- Configuration (
src/app.js):
javascript// src/app.js (inside buildServer, register before routes) const fastifyRateLimit = require('@fastify/rate-limit'); // ... inside buildServer try block ... await fastify.register(fastifyRateLimit, { max: 5, // Max requests per window per key timeWindow: '1 minute', // Time window redis: fastify.redis, // Use existing Redis client for distributed rate limiting keyGenerator: (request) => { // Rate limit based on phone number for OTP actions, or IP for others if (request.body && request.body.phoneNumber) { return request.body.phoneNumber; } // Fallback to IP address for other routes or if phone number not present return request.ip; }, enableDraftSpec: true, // Use standard RateLimit headers errorResponseBuilder: (req, context) => { return { success: false, message: `Rate limit exceeded. Please try again after ${Math.ceil(context.ttl / 1000)} seconds.`, code: 'RATE_LIMIT_EXCEEDED', retryAfter: context.ttl, // Time to wait in milliseconds } }, // Allow list IPs if necessary // allowList: ['127.0.0.1'] }); fastify.log.info('Rate limiting enabled.'); // ... rest of plugin/route registration ...- Explanation: This configuration limits requests keyed by phone number (for OTP endpoints) to 5 per minute. Using Redis makes the rate limiting effective across multiple instances of your application if deployed in a cluster.
- Implementation: Use
- Secret Management: API keys and passwords (
PLIVO_AUTH_TOKEN,REDIS_PASSWORD) are stored in.envand loaded securely. Never commit.envfiles. Use environment variables or a secrets management system (like HashiCorp Vault, AWS Secrets Manager, etc.) in production environments.
Frequently Asked Questions
How secure is SMS OTP for two-factor authentication?
SMS OTP provides moderate security but has known vulnerabilities including SIM swapping, SS7 protocol exploits, and phishing attacks. NIST discouraged SMS-based 2FA since 2016. For high-security applications, use FIDO2/Passkeys or TOTP authenticator apps instead. SMS OTP remains acceptable for low-to-medium risk applications where convenience is prioritized, or as a fallback method. Any MFA is better than no MFA.
How do I generate cryptographically secure OTPs in Node.js?
Use the otp-generator package (which uses Node.js's crypto module) or Node.js's built-in crypto.randomBytes() or crypto.randomInt() methods directly. Never use Math.random() for security-related OTP generation, as it's not cryptographically secure. For time-based OTPs (TOTP), use libraries like otplib that implement HOTP/TOTP algorithms compatible with authenticator apps.
Why use Redis for OTP storage instead of a database?
Redis is ideal for OTP storage because it's extremely fast for simple key-value operations and supports automatic key expiration (TTL). This eliminates the need for cleanup jobs to delete expired OTPs. Redis can handle millions of SET/GET operations per second, making it perfect for high-traffic authentication systems. For permanent audit trails, use Redis for OTP validation and log successful verifications to your primary database.
How long should an OTP be valid?
The standard OTP validity period is 5 minutes (300 seconds), balancing security and user convenience. Shorter periods (2-3 minutes) provide better security but may frustrate users with slow SMS delivery. Longer periods (10+ minutes) increase vulnerability to brute-force attacks and interception. Configure this via the OTP_VALIDITY_SECONDS environment variable in the example code.
What is the difference between OTP and TOTP?
OTP (One-Time Password) is a generic term for any single-use password. TOTP (Time-based One-Time Password) is a specific OTP algorithm that generates codes based on the current time and a shared secret, standardized in RFC 6238. TOTP codes expire after 30 seconds and are generated locally by authenticator apps (Google Authenticator, Authy), making them more secure than SMS OTPs because they don't require network transmission.
How do I get Plivo API credentials?
Log in to your Plivo Console, navigate to "API" → "Keys & Credentials" to find your Auth ID and Auth Token. Then go to "Phone Numbers" → "Your Numbers" to get an SMS-enabled phone number in E.164 format (e.g., +14155551234). Add these to your .env file as PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, and PLIVO_SENDER_NUMBER.
How do I prevent SMS pumping and toll fraud?
Implement rate limiting using @fastify/rate-limit to restrict OTP requests per phone number (e.g., 5 per minute). Add CAPTCHA on the OTP request endpoint to prevent automated attacks. Monitor unusual patterns (many OTPs to international numbers, repeated failed verifications). Consider geofencing to restrict OTP delivery to specific countries. Implement phone number verification before allowing OTP requests.
Can I use this OTP system in production?
Yes, with additional hardening: enable HTTPS/TLS for all connections, implement comprehensive rate limiting and CAPTCHA, secure Redis with password authentication and network restrictions, monitor for unusual patterns and implement alerting, add logging and audit trails for compliance, implement backup verification methods (email, security questions), and regularly review OWASP security guidelines. Consider using TOTP authenticator apps as the primary 2FA method with SMS OTP as a fallback.
Frequently Asked Questions
How to implement 2FA with Node.js and Fastify?
Implement 2FA by integrating SMS OTP using Node.js with the Fastify framework and the Plivo SMS API. This involves setting up routes for requesting and verifying OTPs, generating secure OTPs, and storing them temporarily in Redis with an expiration time for enhanced security.
What is Plivo used for in 2FA?
Plivo is a cloud communications platform that provides the SMS API for sending OTPs to users' phones as the second factor of authentication. It's chosen for its reliable message delivery and easy-to-use API integration.
Why use Redis for storing OTPs?
Redis, an in-memory data store, is ideal for storing OTPs temporarily due to its speed and automatic expiration feature (TTL). It efficiently handles short-lived data like OTPs, avoiding complex database cleanup processes.
When should I implement rate limiting in OTP verification?
Implement rate limiting for both OTP requests and verifications to protect against brute-force attacks. This prevents attackers from repeatedly trying different OTPs or flooding users with SMS messages (SMS pumping).
Can I use Math.random() for OTP generation?
No, `Math.random()` isn't cryptographically secure. Use libraries like `otp-generator`, which utilizes Node.js's `crypto` module for stronger randomness suitable for security-sensitive applications.
How to set up Plivo for sending SMS OTPs?
Obtain your Auth ID, Auth Token, and an SMS-enabled Plivo number from the Plivo Console. Store these securely as environment variables (`PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_SENDER_NUMBER`) and use them to initialize the Plivo Node.js SDK in your application.
What is the purpose of Fastify schemas in this project?
Fastify schemas perform request validation before reaching your core logic. This enhances security by blocking malformed requests and potential injection attempts. Schemas also implicitly document the API.
How does the OTP system handle errors?
The system uses try-catch blocks, logging, and Fastify schemas for handling errors. Schemas handle validation errors (400 Bad Request), and try-catch blocks within services and routes manage service-specific and unexpected errors (500 Internal Server Error).
What is the architecture of the 2FA implementation?
The architecture involves a user interacting with a Fastify API. The API generates an OTP, stores it in Redis, and uses Plivo to send it via SMS to the user. Verification happens against the Redis-stored OTP.
Why does the project use Fastify?
Fastify is a high-performance Node.js web framework chosen for speed and extensibility. It features built-in schema validation and a developer-friendly API, contributing to efficient and maintainable code.
How to request and verify an OTP?
Request an OTP by sending a POST request to the `/request-otp` endpoint with the user's phone number. Verify the received OTP with a POST request to `/verify-otp`, including the phone number and OTP.
Where should OTPs be stored?
OTPs are stored temporarily in Redis with a short Time-To-Live (TTL). This provides fast access and automatic deletion after expiration, ensuring they don't persist longer than necessary.
What are the prerequisites for this project?
You need Node.js and npm (or yarn), a terminal, a Plivo account with API credentials, a running Redis server, and basic knowledge of Node.js, APIs, and asynchronous programming.
What technologies are used in this project?
This project leverages Node.js, Fastify, Plivo, Redis, the `otp-generator` library, and environment variable management tools like `dotenv` or `@fastify/env`.