Design rate limiting for the /login endpoint to stop credential stuffing attacks, using dual keys (per-account and per-IP), strict exactness with no edge burst tolerance, progressive lockout/backoff behavior, and fail-closed policy.
Core challenge: Login rate limiting is fail-closed (unlike most rate limiters). A false-negative (letting an attacker through) is worse than a false-positive (temporarily locking a legitimate user). Dual keying is essential: per-IP catches botnets rotating usernames; per-account catches distributed attacks targeting one account.
5
attempts per account
per 15 minutes
20
attempts per IP
per 15 minutes
Dual Key
per-account + per-IP
both must pass
Fail-Closed
security > availability
block on uncertainty
Functional Requirements
What the system must do · the core login protection behaviours
Must Have (Core)
1.Dual-key rate limiting (per-account + per-IP) — both must pass 2.Progressive lockout escalation (15min → 1hr → 24hr → manual unlock) 3.Fail-closed policy — deny on uncertainty (opposite of normal rate limiters) 4.Timing-safe responses — same latency for valid/invalid accounts (prevent enumeration) 5. Successful login resets per-account counter (legitimate user recovery) 6.Email alert to account owner on persistent attack (Level 3+)
Out of Scope (this design)
✗CAPTCHA implementation (triggered by this system, implemented separately) ✗MFA flows (complementary but separate authentication layer) ✗Device fingerprinting ML (behavioral analysis pipeline) ✗Geographic anomaly detection (IP reputation / impossible travel) ✗Password breach database checks (separate credential hygiene service)
Non-Functional Requirements
The quality constraints that shape every architectural decision — security-first
Property
Target
Why It Matters / Design Impact
Accuracy
EXACT count (zero tolerance for boundary burst)
Security critical. Even 1 extra attempt = 20% increase in attack surface on a 5-attempt limit.
Fail mode
Fail-CLOSED (deny on uncertainty)
Opposite of normal rate limiters. A missed attacker is worse than briefly locking a legitimate user.
Latency
<5ms overhead acceptable
Security > speed for /login. Users expect login to take ~500ms anyway. 5ms is invisible.
Storage
Redis Sorted Set (timestamps, not counters)
Sorted set of exact timestamps enables precise sliding window. No approximation allowed.
Privacy
No password logging
Only attempt timestamps stored. No actual credentials, no password hashes in rate limit store.
Key tension:Security vs. Availability. We choose security · if Redis is unreachable, DENY all login attempts (fail-closed). Better to briefly lock everyone than let attackers through during an outage. This is the opposite of API rate limiting which fails open.
Scale Estimation
Given numbers from the question → derive infrastructure sizing for login protection
Sorted Set with max 15 entries × 8 bytes per entry (timestamp)
~120 bytes/key
For 1M attacked accounts: 1M × 120 bytes = ~120MB. Comfortable for Redis.
5
Redis memory (per-IP)
100K unique IPs × 20 entries × 8 bytes
~16MB
Trivial. Even 1M IPs would only be 160MB.
Interview tip: Start with normal traffic (trivial), then jump to attack scenario. Show the Redis ops math under attack conditions — this proves the system can handle a botnet without breaking. The key insight: sorted sets have slightly higher memory than counters but give you exact window enforcement.
APIs
Rate limit check runs BEFORE authentication logic · prevents password verification under attack
POST/login
Rate limit check executes BEFORE password verification. On failure: increment both per-account and per-IP counters. On success: reset per-account counter only (ZREMRANGEBYSCORE).
// Request
POST /login
{ "email": "user@example.com", "password": "..." }
// Flow: rate_check → authenticate → response // If rate limited before auth:
HTTP 429 + Retry-After: 900 (escalating)
429Rate Limited Response (same format as auth failure)
Returns same error format as authentication failure to prevent account enumeration. Retry-After escalates with lockout level.
// Response (identical format to wrong-password response)
HTTP 429
Retry-After: 900 // escalates: 30 → 60 → 300 → 900
{
"error": "authentication_failed",
"message": "Invalid credentials" // same msg as wrong password!
} // Note: response time is constant (timing-safe) regardless of reason
Data Model
Redis Sorted Sets for exact timestamp tracking + lockout state
Legitimate owner recovers their account. IP counter still protects against horizontal attacks.
Sliding window log: Store each failed attempt timestamp in a Redis sorted set. On new attempt: ZREMRANGEBYSCORE to trim entries older than window, ZCARD to count remaining. If count ≥ threshold → reject. Exact — no approximation, no edge burst.
Progressive backoff response: Don't just return 429. Return increasing Retry-After values: 30s → 60s → 300s → 900s. After threshold, require CAPTCHA or email verification to unlock. Never reveal whether the account exists (timing-safe response).
Why NOT sliding window counter here:Approximate counters can allow 1-2 extra attempts at window boundaries — unacceptable for security. With 5-attempt limit, even 1 extra attempt is a 20% increase in attack surface. Sliding window log guarantees exactness.