·9 min read

Rate Limiting Is Broken on Most Apps, Including Yours

IP-based limits, X-Forwarded-For spoofing, the fixed window 2x burst hole, and why most rate limiting implementations give you false confidence.

SecurityBackendRedisAPI DesignNode.js

I've reviewed a lot of code. And I'd say 80% of rate limiting implementations I've seen have at least one hole that makes them trivially bypassable. Not because the developers were careless, the docs make it look easy, the library says "production ready," and the tests pass.

But the tests don't test for the things that actually matter.

Let's go through the real problems.

The IP Address Problem

Most rate limiting is keyed on IP address. That's fine until you realize:

  1. IPv4 addresses can be easily rotated via residential proxy networks. You can buy access to a pool of millions of real residential IPs for roughly $3/GB. Rotating one per request is trivial.

  2. A single IP might represent thousands of legitimate users (corporate NAT, university network, large ISP with CGNAT). Block that IP and you've locked out everyone behind it.

  3. IPv6 means attackers can have a /64 subnet, that's 18 quintillion addresses, from a single ISP allocation. Blocking individual IPs is pointless.

IP-based rate limiting is a first line of defence against lazy bots. It's not a security control. Don't treat it like one.

For anything that matters, login endpoints, password resets, payment flows, you need to rate limit on account identifier, not IP. Rate limit on the email address being attempted, the user ID in the session, the API key. Things the attacker can't trivially rotate.

X-Forwarded-For Is Trivially Spoofed

Here's a mistake I see constantly in Express/Node apps:

const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

X-Forwarded-For is a header. Headers are set by the client. The client can put anything in there.

curl -H "X-Forwarded-For: 1.2.3.4" https://yourapi.com/login

If your rate limiter reads this header directly, an attacker sends a different fake IP with every request and your rate limit never triggers.

The correct approach depends on your infrastructure:

// If behind a trusted proxy (nginx, Cloudflare, load balancer):
// configure your proxy to set a verified header and read ONLY that
// In Express:
app.set('trust proxy', 1); // trust first proxy in chain
const ip = req.ip;         // Express resolves this correctly

// Better: use the rightmost IP in X-Forwarded-For that YOU added
// Your proxy appends the real client IP, read from the right

Never use req.headers['x-forwarded-for'].split(',')[0] as a rate limit key unless you fully trust every proxy in your chain to not be manipulated. That's the leftmost IP, the one the client controls.

If you're behind Cloudflare, use CF-Connecting-IP. If you're on AWS ALB, use X-Forwarded-For but only the last IP in the chain (the one ALB added). Know your infrastructure.

The Fixed Window 2x Burst Hole

Fixed window rate limiting looks like this: allow 100 requests per minute, reset the counter every minute on the clock boundary (:00, :01, etc.).

Seems fine. But watch what happens:

11:59:50  →  user sends 100 requests  (uses up the 11:59 window)
12:00:00  →  window resets
12:00:05  →  user sends 100 more requests  (new window, fresh 100)

In a 10-second window spanning a minute boundary, the attacker got 200 requests. Every fixed window implementation has this 2x burst at the boundary. If your limit is 100 req/min, the real effective limit is 200 req in any 10-second period.

For most apps this doesn't matter. For login endpoints, it does.

The fix is sliding window:

At any point in time, count requests in the past 60 seconds.
Not "in this calendar minute", in the past 60 seconds from now.

With sliding window there's no boundary to exploit. The window always covers exactly the last N seconds.

Sliding Window in Redis

A clean sliding window implementation using Redis sorted sets:

import { Redis } from "@upstash/redis";

const redis = new Redis({ url: process.env.UPSTASH_URL, token: process.env.UPSTASH_TOKEN });

async function isRateLimited(key: string, limit: number, windowSeconds: number): Promise<boolean> {
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;

  const pipeline = redis.pipeline();
  // Remove entries outside the window
  pipeline.zremrangebyscore(key, 0, windowStart);
  // Count remaining entries
  pipeline.zcard(key);
  // Add current request with timestamp as score
  pipeline.zadd(key, { score: now, member: `${now}-${Math.random()}` });
  // Expire the key so it doesn't sit in Redis forever
  pipeline.expire(key, windowSeconds * 2);

  const results = await pipeline.exec();
  const count = results[1] as number;

  return count >= limit;
}

Use it like:

const limited = await isRateLimited(`login:${email}`, 5, 300); // 5 attempts per 5 minutes
if (limited) return res.status(429).json({ error: "Too many attempts" });

This is keyed on email, not IP. Uses sliding window. No 2x burst hole.

The tradeoff: sorted sets use more memory than simple counters. For high-traffic endpoints you might want a leaky bucket or token bucket instead.

Token Bucket: The One You Should Actually Use

Token bucket is conceptually simple:

  • Each "user" (or key) has a bucket with a max capacity of N tokens
  • Tokens refill at a constant rate (e.g., 1 token per second)
  • Each request costs 1 token
  • If the bucket is empty, the request is rejected

Why this is better than sliding window for most cases:

  1. Allows bursting, a user can send 10 requests instantly if they haven't made requests recently. Their bucket is full. This is how real legitimate users behave.
  2. No boundary exploit, continuous refill, no reset event to exploit.
  3. Cheap to store, just two values: current tokens and last refill time.

Simple Redis implementation:

async function tokenBucket(
  key: string,
  capacity: number,
  refillRate: number // tokens per second
): Promise<boolean> {
  const now = Date.now() / 1000; // seconds

  const data = await redis.hmget(key, "tokens", "last_refill");
  let tokens = data[0] ? parseFloat(data[0] as string) : capacity;
  const lastRefill = data[1] ? parseFloat(data[1] as string) : now;

  // Add tokens for time elapsed
  const elapsed = now - lastRefill;
  tokens = Math.min(capacity, tokens + elapsed * refillRate);

  if (tokens < 1) {
    // Bucket empty, rejected
    await redis.hmset(key, { tokens: tokens.toFixed(4), last_refill: now });
    await redis.expire(key, Math.ceil(capacity / refillRate) * 2);
    return true; // is rate limited
  }

  // Consume one token
  await redis.hmset(key, { tokens: (tokens - 1).toFixed(4), last_refill: now });
  await redis.expire(key, Math.ceil(capacity / refillRate) * 2);
  return false;
}

This isn't atomic (race condition between read and write under high concurrency). For production, wrap in a Lua script or use a library like rate-limiter-flexible which handles this correctly.

The Response Header You're Probably Not Sending

When you do rate limit someone, tell them. Clients (especially legitimate API consumers) need to know when to back off.

res.set({
  'X-RateLimit-Limit': limit,
  'X-RateLimit-Remaining': remaining,
  'X-RateLimit-Reset': resetTimestamp,   // Unix timestamp
  'Retry-After': secondsUntilReset,      // 429 responses
});

Retry-After is a standard HTTP header. Well-behaved clients respect it and back off automatically. If you don't send it, you'll get thundering herd, all the clients that got 429 retry at the same time.

What Actually Works

Honest answer: layered limits.

  • Cloudflare / CDN level, blocks volumetric attacks before they hit your origin. Cheap, no code.
  • IP-based limit at the edge, catches dumb bots. Not security, just noise reduction.
  • Account/identifier-based limit in app, the actual security control. Keyed on email, user ID, API key.
  • CAPTCHA after N failures, for login endpoints, not for APIs.
  • Exponential backoff on failures, double the lockout period each time. First lockout: 1 min. Second: 2 min. Third: 4 min. Makes brute force economically infeasible.

No single layer is enough. The IP limit is useless against proxies. The account limit is useless if the attacker has a list of valid usernames and one attempt per account. Layering them raises the cost of attack.

Rate limiting is not a checkbox. It's a tradeoff between security and friction for real users. Know what you're protecting, key on the right identifier, and pick the algorithm that matches your burst tolerance.