2FA with OTP and SMS
Level Up Your App's Security: A Beginner's Guide to 2FA with Node.js, Hapi, Redis, and Twilio
Hey there, future tech leader! š
If you're building applications, you've probably heard about the importance of security. One of the most effective ways to protect your users' accounts is with Two-Factor Authentication (2FA). Today, we're going to demystify 2FA and build a complete implementation from scratch.
What is 2FA?
It's a security process that requires users to provide two different authentication factors to verify themselves. It's often referred to as "something you know" (your password) and "something you have" (your phone).
In this tutorial, we'll build a simple API where a user logs in, receives a One-Time Password (OTP) on their phone via SMS, and then uses that OTP to complete their login.
Our Tech Stack "Dream Team":
- Node.js with Hapi.js: A powerful and configuration-centric framework that makes building robust APIs a breeze.
- Redis with Catbox: Redis is an incredibly fast in-memory data store. We'll use it via Hapi's caching library, Catbox, to temporarily store our OTPs. This is perfect because OTPs should expire quickly!
- Twilio: The go-to service for developers to programmatically send and receive SMS, make calls, and more.
Ready to build? Let's dive in!
Prerequisites
- Node.js and npm: Make sure you have a recent version installed.
- A Twilio Account:
- Sign up for a free Twilio account.
- Get a Twilio phone number from your console.
- Find your Account SID and Auth Token on your Twilio Console dashboard.
- Redis Server: You need a Redis instance running. The easiest way for local development is using Docker:
docker run --name my-redis -p 6379:6379 -d redis
This command will start a Redis container and make it available on localhost:6379
.
Step 1: Setting Up Our Node.js Project
Let's get our project structure in place. Open your terminal and run these commands:
# Create a new project directory
mkdir hapi-2fa-demo
cd hapi-2fa-demo
# Initialize a new Node.js project
npm init -y
# Install our dependencies
npm install @hapi/hapi catbox-redis twilio dotenv joi nanoid
What are these packages?
- @hapi/hapi: The Hapi.js framework itself.
- catbox-redis: The adapter to let Hapi's cache (Catbox) talk to our Redis server.
- twilio: The official Twilio Node.js SDK.
- dotenv: To load our secret credentials (like Twilio keys) from a .env file.
- joi: For powerful and easy request validation.
- nanoid: To generate secure, random OTPs. It's better than Math.random()!
Now, create two files in your project root: index.js
and .env
.
Your .env
file is where you'll store your secrets. Never commit this file to Git!
File: .env
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_from_twilio
TWILIO_PHONE_NUMBER=+15017122661
# Your user's phone number for testing
# Make sure it's a verified number in your Twilio trial account!
USER_PHONE_NUMBER=+1234567890
Step 2: Initializing the Hapi Server with Redis Cache
Now for the fun part! Let's write some code. Open index.js
and set up our basic Hapi server. The most important part here is configuring the cache to use Redis.
File: index.js
'use strict';
// Load environment variables from .env file
require('dotenv').config();
const Hapi = require('@hapi/hapi');
const Joi = require('joi');
const { customAlphabet } = require('nanoid');
const twilio = require('twilio')(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
// Our main function to start the server
const start = async () => {
// Configure the server
const server = Hapi.server({
port: 3000,
host: 'localhost',
// This is the magic part! We're telling Hapi to use Redis for caching.
cache: [
{
name: 'redis_cache',
provider: {
constructor: require('catbox-redis'),
options: {
partition: 'otp_cache', // A "namespace" for our cache in Redis
host: '127.0.0.1',
port: 6379,
database: 0,
}
}
}
]
});
// Create a dedicated cache segment for our OTPs.
// OTPs will be stored for 5 minutes (300,000 ms).
const otpCache = server.cache({
cache: 'redis_cache',
segment: 'otps',
expiresIn: 5 * 60 * 1000, // 5 minutes in milliseconds
});
// --- We will add our routes here in the next steps ---
await server.start();
console.log('ā
Server running on %s', server.info.uri);
};
// A safety check for unhandled errors
process.on('unhandledRejection', (err) => {
console.error(err);
process.exit(1);
});
// Start the server!
start();
Step 3: The Login Flow - Requesting the OTP
Our first endpoint will be /login
. A user will POST their credentials here. We'll simulate a successful password check and then generate and send the OTP.
File: index.js (add this route)
// ... inside the start() function
// ROUTE 1: Request an OTP
server.route({
method: 'POST',
path: '/login',
options: {
description: 'Simulate login and send an OTP',
tags: ['api'],
validate: {
payload: Joi.object({
// For this demo, the username is the key we use to store the OTP
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().required() // In a real app, you'd check this!
})
}
},
handler: async (request, h) => {
const { username, password } = request.payload;
// --- In a real-world app, you would first validate the user's password here! ---
// For our demo, we'll assume the password is correct and proceed.
console.log(`Simulating login for user: ${username}`);
// 1. Generate a secure 6-digit OTP
const nanoid = customAlphabet('1234567890', 6);
const otp = nanoid();
// 2. Store the OTP in our Redis cache.
// The key is the username, and the value is the OTP.
// It will automatically expire after 5 minutes thanks to our cache config.
await otpCache.set(username, otp);
console.log(`š OTP for ${username} is ${otp}. Storing in Redis...`);
try {
// 3. Send the OTP via Twilio SMS
const message = await twilio.messages.create({
body: `Your AwesomeApp verification code is: ${otp}`,
from: process.env.TWILIO_PHONE_NUMBER,
to: process.env.USER_PHONE_NUMBER // Using the test number from .env
});
console.log(`āļø SMS sent successfully! SID: ${message.sid}`);
} catch (err) {
console.error("š„ Error sending SMS via Twilio:", err.message);
return h.response({ statusCode: 500, error: 'Failed to send OTP' }).code(500);
}
// 4. Respond to the client
return h.response({
statusCode: 200,
message: `OTP sent to configured phone number. Please verify.`
}).code(200);
}
});
Step 4: The Login Flow - Verifying the OTP
Once the user receives the OTP on their phone, they need a way to submit it. We'll create a /verify
endpoint for this.
File: index.js (add this route)
// ... inside the start() function, after the /login route
// ROUTE 2: Verify the OTP
server.route({
method: 'POST',
path: '/verify',
options: {
description: 'Verify the OTP and complete login',
tags: ['api'],
validate: {
payload: Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
otp: Joi.string().length(6).required()
})
}
},
handler: async (request, h) => {
const { username, otp } = request.payload;
// 1. Try to retrieve the OTP from our Redis cache
const cachedOtp = await otpCache.get(username);
// 2. Check if an OTP exists for this user
if (!cachedOtp) {
console.log(`ā Verification failed for ${username}. No OTP found in cache (it may have expired).`);
return h.response({ statusCode: 400, error: 'Invalid or expired OTP' }).code(400);
}
// 3. Compare the submitted OTP with the cached one
if (cachedOtp !== otp) {
console.log(`ā Verification failed for ${username}. Submitted OTP ${otp} did not match cached OTP ${cachedOtp}.`);
return h.response({ statusCode: 400, error: 'Invalid or expired OTP' }).code(400);
}
// 4. SUCCESS! OTP is correct.
console.log(`ā
OTP for ${username} verified successfully!`);
// IMPORTANT: Invalidate the OTP after successful use to prevent reuse attacks.
await otpCache.drop(username);
// In a real app, you would now generate a JWT or session token
// and send it back to the user.
return h.response({
statusCode: 200,
message: 'Login successful!',
// token: 'dummy-jwt-for-demonstration'
}).code(200);
}
});
Step 5: Testing Our 2FA Flow
Your index.js
file is now complete! Let's fire up the server and test the whole flow.
node index.js
You should see: ā
Server running on http://localhost:3000
Request an OTP: Open a new terminal and use curl (or any API client like Postman) to hit our /login endpoint.
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "fakepassword123"}'
You should get this response:
{"statusCode":200,"message":"OTP sent to configured phone number. Please verify."}
Check your server logs. You'll see something like this:
Simulating login for user: testuser
š OTP for testuser is 831094. Storing in Redis...
āļø SMS sent successfully! SID: SMxxxxxxxxxxxxxxxxxxxxxx
Check Your Phone! You should receive an SMS on the USER_PHONE_NUMBER
you configured in your .env
file.
Verify the OTP: Now, use the code you just received to hit the /verify endpoint.
# Replace 831094 with the actual code you received
curl -X POST http://localhost:3000/verify \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "otp": "831094"}'
Success! You should see:
{"statusCode":200,"message":"Login successful!"}
And in your server logs: ā
OTP for testuser verified successfully!
Test Failure Cases (This is important!):
Try verifying with the wrong OTP:
curl -X POST http://localhost:3000/verify -H \"Content-Type: application/json\" -d '{"username": "testuser", "otp": "000000"}'
You'll get an error: {"statusCode":400,"error":"Invalid or expired OTP"}
Try verifying with the correct OTP a second time: Because we used otpCache.drop()
, the OTP is now gone. Trying the same command again will also result in the "Invalid or expired OTP" error. This is exactly what we want!
Conclusion and Next Steps
Congratulations! š You have successfully built a secure 2FA login flow using Node.js, Hapi, Redis, and Twilio.
- Generate and send OTPs using Twilio.
- Temporarily and securely store OTPs in Redis using Hapi's Catbox cache.
- Set an expiry time for OTPs to enhance security.
- Validate the OTP and invalidate it after use.
For a production-ready application, consider these improvements:
- Real User Database: Integrate a real database (like PostgreSQL or MongoDB) and use bcrypt to hash and check passwords.
- Rate Limiting: Prevent users from spamming the /login endpoint to avoid running up your Twilio bill.
- JWTs: Upon successful verification, generate a JSON Web Token (JWT) to manage the user's logged-in session.
- More 2FA Options: Add support for authenticator apps (like Google Authenticator) for users who prefer them over SMS.
Security is a journey, not a destination, and adding 2FA is a massive step in the right direction. Happy coding!