I thought this would be a simple backend task.
Generate OTP → Store in Redis → Add expiration → Verify user.
Done.
My first implementation looked like this:
await redis.set(
otpKey(phone),
otp,
"EX",
60
);
OTP expires after 60 seconds.
Looks perfect.
Until I asked myself:
What actually stops someone from guessing forever?
Nothing 😭
Someone could still keep trying:
123456
111111
999999
000000
654321
222222
The OTP expires.
Brute force attempts do not.
That was the moment I realized:
TTL solves expiration.
TTL does NOT solve security.
Version 1: Store Only OTP
Initially Redis stored only:
"123456"
Simple.
But there was no information about:
- Failed attempts
- Blocking state
- Verification behavior
- Security tracking
So I changed the design.
Instead of storing only the OTP:
{
"otp": "123456",
"attempts": 0,
"maxAttempts": 3,
"blockedUntil": null
}
Redis stopped feeling like cache.
It started acting like a lightweight state engine.
Adding Brute Force Protection
Wrong OTP?
Increase attempts:
otpData.attempts++;
After 3 failures:
otpData.blockedUntil =
Date.now() + 60000;
Users are blocked for 60 seconds.
Problem solved.
Or at least…
That’s what I thought 😭
The Redis Bug I Didn’t Expect
When verification failed I updated Redis:
await redis.set(
otpKey(phone),
JSON.stringify(
otpData
),
"EX",
60
);
Looks harmless.
But there was a hidden problem.
Imagine this:
- OTP created
- TTL = 60 sec
- User fails at second 55
- Redis updates state
- TTL becomes 60 again
The OTP suddenly lives longer.
I accidentally extended authentication lifetime after every failed attempt.
Not ideal.
The Fix
Before updating Redis:
const ttl =
await redis.ttl(
otpKey(phone)
);
Reuse remaining TTL:
await redis.set(
otpKey(phone),
JSON.stringify(
otpData
),
"EX",
ttl
);
Now:
- OTP created → 60 sec
- User fails at second 55
- Remaining TTL = 5
- Redis update keeps TTL = 5
Expiration stays correct ✅
Biggest Lesson From This Project
I started with:
Redis = Cache
I finished with:
Redis = State Engine
TTL → Expiration
Attempts → Security
Blocking → Authentication logic
Redis became part of application behavior.
That was the biggest lesson.
Things I Want To Improve Next
1. Rate limiting per IP
Prevent OTP spam.
2. Redis Hashes
Avoid rewriting entire JSON objects.
3. Atomic updates
Current flow:
GET
↓
Modify
↓
SET
Possible improvements:
- Transactions
- WATCH / MULTI
- Lua scripts
4. Handle race conditions
Two verification requests arriving together could create inconsistent state.
5. Add resend cooldown
Send OTP
↓
Wait 30 sec
↓
Allow resend
Final Thought
I started building:
OTP + Redis + TTL
I ended up learning:
- Authentication design
- State management
- Expiration handling
- Brute-force protection
- Backend security
Small project.
Big Redis lesson.
Day 2 of learning Redis 🚀
If you’ve built OTP systems before:
Would you keep state in Redis?
Or move some logic elsewhere?
Top comments (0)