Storing short-lived One-Time Passwords (OTPs) in PostgreSQL or MySQL is a terrible idea. I used to do this. My database was constantly bloated with millions of expired, useless rows. I had to write cron jobs to clean them up, which just added more disk writes and slowed down our actual production queries.
We’re going to build an OTP generation and verification system using Redis and Node.js. Redis is the perfect tool here. It runs in-memory, handles automatic key expiration (TTL) out of the box, and easily shrugs off the high-throughput write and read cycles that would choke a traditional database during peak traffic.

Why Relational Databases Fail at OTPs
Think about the lifecycle of an OTP. A user requests a code, it’s valid for 5 minutes, they verify it, and it’s dead. If you write this to SQL, you’re doing a heavy disk write. When they verify it, you do a read, then another write to delete it or mark it as used.
If you get hit with a spike of login requests, your database gets hammered with write amplification. Plus, you have to clean up the garbage. Forget to set up a cron job to prune expired codes, and your tables balloon. Redis solves this. It stores everything in-memory and has built-in Time-To-Live (TTL). Once the timer runs out, Redis deletes the key. No cleanup scripts, no disk writes.
We can use the Redis SET command with expiration arguments to handle storage and expiration in one atomic step.
Setting Up Node.js and Redis
First, you need Redis running locally. The fastest way to spin one up without messing up your local machine is with Docker. Run this in your terminal:
docker run --name otp-redis -p 6379:6379 -d redis
Now, let’s initialize a new Node.js project. We’ll install the official Redis client and Express to build our API endpoints.
mkdir node-redis-otp
cd node-redis-otp
npm init -y
npm install redis express dotenv crypto
Create a .env file in the root of your project to store your connection details. Don’t hardcode these.
PORT=3000
REDIS_URL=redis://localhost:6379
OTP_TTL_SECONDS=300
Designing the OTP Storage Scheme
We need to track two things in Redis: the active OTP code itself, and the number of failed attempts so someone can’t just brute-force guess the code. We’ll use a clean key naming convention to keep things organized.
For the active OTP, we’ll use otp:user_identifier (where the identifier is an email or phone number). For tracking failed attempts, we’ll use attempts:user_identifier.
Let’s set up our Redis client in redisClient.js. We want a single, shared connection instance that we can import anywhere.
const { createClient } = require('redis');
require('dotenv').config(); const client = createClient({ url: process.env.REDIS_URL
}); client.on('error', (err) => console.error('Redis Client Error', err)); (async () => { await client.connect(); console.log('Connected to Redis successfully');
})(); module.exports = client;
Building the Secure OTP Generator
Please do not use Math.random() for security codes. It’s predictable and cryptographically weak. Instead, we’ll use Node’s native crypto module to generate a truly random 6-digit number.
Create a file named otpService.js. This service handles generating the code, saving it to Redis with a TTL, and checking if the user is on a resend cooldown so they don’t spam our SMS gateway.
const crypto = require('crypto');
const redisClient = require('./redisClient'); const OTP_TTL = parseInt(process.env.OTP_TTL_SECONDS) || 300;
const COOLDOWN_TTL = 60; // 1 minute resend limit function generateSecureOTP() { // Generates a cryptographically secure 6-digit number return crypto.randomInt(100000, 999999).toString();
} async function saveOTP(identifier, otp) { const otpKey = `otp:${identifier}`; const cooldownKey = `cooldown:${identifier}`; // Check if user is on cooldown const isOnCooldown = await redisClient.get(cooldownKey); if (isOnCooldown) { throw new Error('Please wait 60 seconds before requesting a new OTP'); } // Save OTP with TTL await redisClient.set(otpKey, otp, { EX: OTP_TTL }); // Set a resend cooldown key await redisClient.set(cooldownKey, 'active', { EX: COOLDOWN_TTL }); // Reset failed attempts when a new OTP is requested await redisClient.del(`attempts:${identifier}`); return true;
}
This setup solves two annoying problems at once. First, the OTP automatically expires after 5 minutes. Second, we prevent users from spamming our SMS or email gateways by enforcing a 60-second cooldown using a dedicated key. If you’re integrating webhooks to trigger these notifications, you might also want to look at how to prevent duplicate webhook processing with Redis and Express to avoid double-sending.
Implementing Verification and Brute-Force Protection
If you don’t rate-limit your verification endpoint, an attacker can write a simple script to brute-force all 1,000,000 combinations in a few minutes. We have to track failed attempts and lock the user out after 3 failed tries.
Let’s add the verification logic to otpService.js. We’ll fetch the saved OTP, compare it to the user’s input, and increment our failure counter if they get it wrong.
async function verifyOTP(identifier, userOTP) { const otpKey = `otp:${identifier}`; const attemptsKey = `attempts:${identifier}`; const maxAttempts = 3; // Check if user is locked out const attempts = await redisClient.get(attemptsKey); if (attempts && parseInt(attempts) >= maxAttempts) { throw new Error('Too many failed attempts. Request a new OTP.'); } const storedOTP = await redisClient.get(otpKey); if (!storedOTP) { throw new Error('OTP has expired or does not exist'); } if (storedOTP !== userOTP) { // Increment failed attempts and set TTL on the attempts key const currentAttempts = await redisClient.incr(attemptsKey); if (currentAttempts === 1) { // Set attempts key to expire with the OTP await redisClient.expire(attemptsKey, OTP_TTL); } const remaining = maxAttempts - currentAttempts; if (remaining <= 0) { // Delete the OTP immediately on lockout await redisClient.del(otpKey); throw new Error('Too many failed attempts. Your OTP has been invalidated.'); } throw new Error(`Invalid OTP. You have ${remaining} attempts remaining.`); } // Success! Clean up Redis keys immediately to prevent replay attacks await redisClient.del(otpKey); await redisClient.del(attemptsKey); await redisClient.del(`cooldown:${identifier}`); return true;
} module.exports = { generateSecureOTP, saveOTP, verifyOTP
};
Deleting the OTP immediately after a successful verification is critical. If you don’t, you leave the door open for replay attacks where someone intercepts the code and reuses it within that 5-minute window.
Building the Express API Endpoints
Now let’s expose these services via HTTP. Create a file named server.js. We’ll build a /request-otp endpoint and a /verify-otp endpoint.
const express = require('express');
const { generateSecureOTP, saveOTP, verifyOTP } = require('./otpService'); const app = express();
app.use(express.json()); // Endpoint to request an OTP
app.post('/api/otp/request', async (req, res) => { const { email } = req.body; if (!email) { return res.status(400).json({ error: 'Email is required' }); } try { const otp = generateSecureOTP(); await saveOTP(email, otp); // In production, send this via Twilio, SendGrid, or Listmonk console.log(`[EMAIL SENT TO ${email}]: Your code is ${otp}`); return res.status(200).json({ message: 'OTP sent successfully' }); } catch (error) { return res.status(429).json({ error: error.message }); }
}); // Endpoint to verify the OTP
app.post('/api/otp/verify', async (req, res) => { const { email, otp } = req.body; if (!email || !otp) { return res.status(400).json({ error: 'Email and OTP are required' }); } try { await verifyOTP(email, otp); // At this point, you would typically issue a JWT or set an Express Session return res.status(200).json({ message: 'Verification successful' }); } catch (error) { return res.status(400).json({ error: error.message }); }
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
If you plan to use sessions after this verification step, check out how to store Express sessions in Redis to keep your backend completely stateless.
What Broke When I Tested This
When I first shipped a system like this, we hit a nasty race condition. Users on slow mobile connections would double-tap the “Submit” button. This fired off two identical POST requests to /api/otp/verify at almost the exact same millisecond.
The first request would verify the OTP, delete it from Redis, and return a success response. The second request, arriving a millisecond later, would look for the OTP, find nothing (since the first request already deleted it), and throw an "OTP has expired or does not exist" error.
The frontend caught the second error, overwrote the success state, and showed a confusing error message to a user who actually entered the correct code. To fix this, you can implement a short-lived distributed lock on the verification endpoint. For more on this pattern, read about how to build a Redis distributed lock in Node.js to prevent race conditions.
Sure, you can disable the submit button in the UI immediately after the first click, but backend-level protection is always safer.
Scaling the System with Rate Limiters
While tracking failed attempts per OTP is great, it won’t stop a malicious actor from hitting your /request-otp endpoint millions of times with different email addresses, which will quickly drain your SMS API budget. You need a global rate limiter to prevent this.
You can implement a sliding window rate limiter directly in Redis to block IP addresses that make too many requests. You can learn how to set this up in our guide on building a Redis sliding window rate limiter in Node.js.
By combining an IP-based rate limiter on the request endpoint with our per-user attempt tracker on the verification endpoint, your authentication flow becomes incredibly secure against automated attacks.
Frequently Asked Questions
Why use Redis instead of JWTs for OTPs?
JWTs are stateless and you can’t easily invalidate them before they expire without maintaining a blacklist. If you send an OTP via a JWT, and the user verifies it on the first try, that JWT remains valid for the rest of its lifespan. Redis lets you instantly delete the OTP key upon first use, completely preventing replay attacks.
What happens if the Redis server restarts?
By default, Redis stores data in-memory. If it crashes or restarts, active OTPs are lost, and users will have to request a new code. For transient data like OTPs, this is usually fine. If you absolutely need durability, you can enable AOF (Append Only File) persistence in your Redis configuration.
How long should the OTP TTL be?
A standard window is between 3 to 5 minutes (180 to 300 seconds). Anything longer increases the attack window; anything shorter might expire before the user receives the SMS or email due to carrier delays.
Should I hash the OTP inside Redis?
For high-security environments, yes. You can use Node’s crypto.createHash('sha256') to hash the OTP before saving it to Redis, and hash the user input before comparing. This ensures that even if someone gains access to your Redis instance, they can’t read active verification codes.
Next Steps for Your Redis Stack
Now that you have a secure, auto-expiring OTP system, you can expand your Redis-backed architecture. If you want to process background tasks like sending the actual emails or SMS messages asynchronously, check out how to build a delayed job queue with Redis and Node.js to handle your outgoing notification delivery without blocking your main API thread.

