How to protect sensitive endpoints (OTP, login) from brute-force attacks using Redis atomic counters—with proper implementation to avoid race conditions.
🔓 The Attack: Brute-force Attempts
Threat Model
Attackers use automated scripts to:
- Guess OTP codes (000000–999999 = 1 million combinations)
- Brute-force login passwords
- Enumerate valid usernames via timing attacks
Without rate limiting, a 6-digit OTP can be cracked in minutes with parallel requests.
🔧 The Solution: Redis Atomic Counters
Key Insight
Redis INCR is atomic—even with 1000 concurrent requests, each increment happens exactly once, in order. No race conditions, no locking overhead.
Basic Pattern (Fixed Window)
|
|
⚠️ The Race Condition Problem
The code above has a subtle bug:
|
|
✅ Correct Implementation: Atomic INCR + EXPIRE
Use the NX option to set TTL only on first creation:
|
|
💻 Implementation: OTP Protection
⚠️ The Check-then-Act Trap
A naive implementation might:
- Check if rate limit exceeded
- If not, verify OTP
- If failed, increment counter
This is vulnerable to concurrent attacks. If 50 requests arrive simultaneously, they all pass step 1 before any increments happen—allowing 50 OTP attempts in one burst.
✅ Rate-Limit-First Pattern
Always increment first, then decide:
|
|
Why Multi-dimensional Rate Limiting?
| Attack Type | userId-only | IP-only | Both ✅ |
|---|---|---|---|
| Single-user brute-force | ✅ Blocked | ❌ Missed | ✅ Blocked |
| Credential stuffing (1M users, 1 attempt each) | ❌ Missed | ✅ Blocked | ✅ Blocked |
| Distributed attack (many IPs, one user) | ✅ Blocked | ❌ Missed | ✅ Blocked |
Key Design Patterns
| Use Case | Key Format | Strategy |
|---|---|---|
| OTP failures | otp_fail:user:{userId} + otp_fail:ip:{ip} |
Multi-dimensional |
| Login failures | login_fail:user:{userId} + login_fail:ip:{ip} |
Multi-dimensional |
| API abuse | api_limit:{ip}:{endpoint} |
Per-IP per-endpoint |
| Global protection | global_limit:{endpoint} |
System-wide limit |
⚠️ Production Caveats
Fixed Window Burst Problem
The simple counter approach is a Fixed Window algorithm. At window boundaries:
|
|
For stricter control, consider:
- Sliding Window Log — Track each request timestamp
- Sliding Window Counter — Weighted average of current + previous window
- Token Bucket — Allow burst up to bucket size, refill at constant rate
Redis Failure Mode
If Redis is unavailable:
- Fail-open: Allow requests (security risk for rate limiting)
- Fail-closed: Deny requests (availability risk)
For security-critical endpoints (OTP, login), fail-closed is usually preferred:
|
|
Distributed Rate Limiting
Redis counters are inherently distributed—all pods read/write the same keys. No additional coordination needed.
But in Redis Cluster, ensure rate limit keys use consistent hashing:
|
|
📊 Why INCR is Concurrency-Safe
Redis is single-threaded for command execution. Even with 1000 concurrent requests:
|
|
Every increment is guaranteed to happen exactly once. No:
- Lost updates
- Duplicate counts
- Race conditions
This is why Redis is the first line of defense for rate limiting—it scales with your attack surface.
📝 Summary
- Atomic
INCR— Guaranteed accurate counting under high concurrency - Atomic INCR + EXPIRE — Use
MULTI/EXECor Lua script to avoid orphan keys - Fixed Window — Simple but has burst problem at boundaries
- Fail-closed — For security endpoints, deny requests if Redis is unavailable
Redis provides a low-overhead, horizontally scalable rate limiting layer that protects your API from brute-force attacks with minimal resources.