[Redis Beyond Caching] Part 5: API Security Rate Limiting

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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import Redis from 'ioredis';
const redis = new Redis();

const RATE_LIMIT = 10;      // max attempts
const WINDOW_SECONDS = 300; // 5 minutes

async function checkRateLimit(userId: string): Promise<boolean> {
  const key = `otp_fail:${userId}`;
  
  // INCR + EXPIRE must be atomic to avoid orphan keys
  const count = await redis.incr(key);
  
  if (count === 1) {
    // First attempt: set expiration
    await redis.expire(key, WINDOW_SECONDS);
  }
  
  return count <= RATE_LIMIT;
}

⚠️ The Race Condition Problem

The code above has a subtle bug:

1
2
3
4
5
Request 1: INCR → returns 1
Request 2: INCR → returns 2
Request 1: EXPIRE (overwrites TTL)
Request 2: EXPIRE (overwrites TTL again)
← If INCR succeeds but EXPIRE fails, key lives forever

✅ Correct Implementation: Atomic INCR + EXPIRE

Use the NX option to set TTL only on first creation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
async function checkRateLimitSafe(userId: string): Promise<boolean> {
  const key = `otp_fail:${userId}`;
  
  const multi = redis.multi();
  multi.incr(key);
  multi.expire(key, WINDOW_SECONDS, 'NX'); // NX: only set if no TTL exists
  
  const results = await multi.exec();
  const count = results[0][1] as number;
  
  return count <= RATE_LIMIT;
}

💻 Implementation: OTP Protection

⚠️ The Check-then-Act Trap

A naive implementation might:

  1. Check if rate limit exceeded
  2. If not, verify OTP
  3. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const RATE_LIMIT = 10;
const WINDOW_SECONDS = 300;

async function verifyOtpSecure(userId: string, ip: string, code: string): Promise<boolean> {
  const userKey = `otp_fail:user:${userId}`;
  const ipKey = `otp_fail:ip:${ip}`;
  
  // 1. INCR first — don't ask, just count
  const multi = redis.multi();
  multi.incr(userKey);
  multi.expire(userKey, WINDOW_SECONDS, 'NX');
  multi.incr(ipKey);
  multi.expire(ipKey, WINDOW_SECONDS, 'NX');
  
  const results = await multi.exec();
  const userCount = results[0][1] as number;
  const ipCount = results[2][1] as number;
  
  // 2. Check limits — reject immediately if exceeded
  if (userCount > RATE_LIMIT) {
    throw new Error('Too many attempts for this account. Please wait.');
  }
  if (ipCount > RATE_LIMIT * 10) { // IP gets higher threshold
    throw new Error('Too many attempts from this IP. Please wait.');
  }
  
  // 3. Only now verify OTP — expensive operation protected
  const isValid = await checkOtpCode(userId, code);
  
  if (isValid) {
    // Success: clear user counter (keep IP counter to prevent automation)
    await redis.del(userKey);
  }
  
  return isValid;
}

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:

1
2
3
Window 1 (00:00–05:00): User makes 10 requests at 04:59
Window 2 (05:00–10:00): User makes 10 requests at 05:01
← 20 requests in 2 seconds, both windows allow up to 10

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:

1
2
3
4
5
6
7
8
9
async function checkRateLimitWithFallback(userId: string): Promise<boolean> {
  try {
    return await checkRateLimitAtomic(userId);
  } catch (error) {
    logger.error('Redis unavailable for rate limiting', error);
    // Fail-closed: deny the request
    return false;
  }
}

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:

1
2
// Good: all rate limit keys for a user go to same shard
const key = `{rate_limit}:otp:${userId}`;

📊 Why INCR is Concurrency-Safe

Redis is single-threaded for command execution. Even with 1000 concurrent requests:

1
2
3
4
5
Request 1: INCR key → 1
Request 2: INCR key → 2
Request 3: INCR key → 3
...
Request 1000: INCR key → 1000

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/EXEC or 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.


References

comments powered by Disqus