Published on

API Rate Limit Exploited — When Your Limits Are Too Easy to Bypass

Authors

Introduction

Most rate limiting implementations are single-dimensional: limit by IP address. Single-dimensional rate limiting is trivially bypassed by any attacker with access to a proxy pool, which costs $5/month. The attackers who matter — credential stuffers, scrapers, API abusers — all use IP rotation. Effective rate limiting must be multi-dimensional: IP addresses provide one signal, but user accounts, device fingerprints, behavioral patterns, and business logic all contribute to a picture that's much harder to fake at scale.

How Attackers Bypass Simple Rate Limits

Rate limit bypass techniques:

1. IP rotation (most common)
Proxy pool: 1,000 IPs for $20/month
Each IP makes 99 requests (under 100/min limit)
Effective throughput: 99,000 requests/min

2. Distributed attack
Attacker controls 10,000 infected home routers
Each makes 5 requests: stays under any per-IP limit
50,000 requests/min from "legitimate" residential IPs

3. Account farming
Creates 1,000 accounts (bypassed with disposable emails)
Each account makes 100 requests under per-account limit
100,000 requests/min total

4. Header spoofing
X-Forwarded-For: rotated IP addresses
Works when your rate limiter trusts this header blindly

5. Timing attacks
Makes exactly N-1 requests per window
Never triggers the limit, but maximum utilization

Fix 1: Multi-Dimensional Rate Limiting

// rate-limiter.ts — multiple dimensions, all must pass
import { RateLimiterRedis } from 'rate-limiter-flexible'

const limiters = {
  // Dimension 1: IP address (catches basic attacks)
  ip: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl_ip',
    points: 200,
    duration: 60,
    blockDuration: 300,
  }),

  // Dimension 2: Authenticated user (survives IP rotation)
  user: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl_user',
    points: 100,
    duration: 60,
    blockDuration: 600,
  }),

  // Dimension 3: Device fingerprint (harder to rotate than IP)
  device: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl_device',
    points: 150,
    duration: 60,
    blockDuration: 3600,
  }),

  // Dimension 4: Endpoint-specific (protect most sensitive endpoints)
  login: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl_login',
    points: 5,      // Only 5 login attempts per minute per IP
    duration: 60,
    blockDuration: 900,
  }),
}

async function applyRateLimits(req: Request, res: Response, next: NextFunction) {
  const ip = getTrustedIP(req)  // See Fix 2
  const userId = req.user?.id
  const deviceId = req.fingerprint?.id  // See Fix 3

  const checks: Promise<any>[] = [
    limiters.ip.consume(ip),
  ]

  if (userId) {
    checks.push(limiters.user.consume(userId))
  }

  if (deviceId) {
    checks.push(limiters.device.consume(deviceId))
  }

  if (req.path === '/api/auth/login') {
    checks.push(limiters.login.consume(ip))
  }

  try {
    await Promise.all(checks)
    next()
  } catch (rlError) {
    const retryAfter = Math.ceil((rlError as any).msBeforeNext / 1000)
    res.set('Retry-After', retryAfter)
    return res.status(429).json({ error: 'Rate limit exceeded', retryAfter })
  }
}

Fix 2: Trust the Right IP Address

// Getting the real client IP — critical to prevent header spoofing
// ❌ Never trust X-Forwarded-For blindly — clients can set it to anything
// ✅ Only trust it if the request comes from your known proxy/CDN

function getTrustedIP(req: Request): string {
  // Your CDN/proxy IPs (Cloudflare ranges, your load balancer)
  const TRUSTED_PROXIES = new Set([
    '10.0.0.0/8',    // Internal load balancers
    '172.16.0.0/12', // Internal
    '192.168.0.0/16', // Internal
    // Add Cloudflare IP ranges from https://www.cloudflare.com/ips/
  ])

  const remoteAddr = req.socket.remoteAddress ?? ''

  // Only use X-Forwarded-For if the request comes from a trusted proxy
  if (isInTrustedRange(remoteAddr, TRUSTED_PROXIES)) {
    const forwarded = req.headers['x-forwarded-for']
    if (forwarded) {
      // X-Forwarded-For: client, proxy1, proxy2
      // Take the first (leftmost) IP — that's the actual client
      return forwarded.split(',')[0].trim()
    }
  }

  // Not from trusted proxy: use direct connection IP
  return remoteAddr
}

// Also: set trust proxy in Express correctly
// app.set('trust proxy', ['loopback', '10.0.0.0/8'])

Fix 3: Device Fingerprinting Beyond IP

// A device fingerprint combines multiple signals that are hard to rotate simultaneously
// Even when IP changes, the fingerprint often stays stable

interface DeviceFingerprint {
  id: string
  signals: {
    userAgent: string
    acceptLanguage: string
    acceptEncoding: string
    timezone: string
    screenResolution?: string  // From frontend
    canvasFingerprint?: string // From frontend
  }
  confidence: 'high' | 'medium' | 'low'
}

function generateServerSideFingerprint(req: Request): string {
  const components = [
    req.headers['user-agent'] ?? '',
    req.headers['accept-language'] ?? '',
    req.headers['accept-encoding'] ?? '',
    req.headers['accept'] ?? '',
    // Do NOT include IP — that's the whole point
  ]

  // Hash the combination
  return crypto
    .createHash('sha256')
    .update(components.join('|'))
    .digest('hex')
    .substring(0, 16)
}

// For browser clients: combine server-side with client-side fingerprint
// sent as X-Device-Fingerprint header from your frontend JavaScript
// (FingerprintJS or similar)

function resolveDeviceId(req: Request): string {
  // Trust client-provided fingerprint if format is valid
  const clientFp = req.headers['x-device-fingerprint']
  if (clientFp && /^[a-f0-9]{32}$/.test(clientFp as string)) {
    return `client_${clientFp}`
  }

  // Fall back to server-derived fingerprint
  return `server_${generateServerSideFingerprint(req)}`
}

Fix 4: Sliding Window vs Fixed Window

// Fixed window rate limiting has a "seam attack" vulnerability:
// Attacker makes 100 requests at 11:59:59 and 100 more at 12:00:01
// They've made 200 requests but never triggered the 100/minute limit

// ❌ Fixed window (vulnerable to seam attack):
const windowStart = Math.floor(Date.now() / 60000) * 60000  // Every minute
const key = `rl:${ip}:${windowStart}`
const count = await redis.incr(key)
await redis.expire(key, 60)

// ✅ Sliding window (no seam attack):
const WINDOW_MS = 60_000
const MAX_REQUESTS = 100

async function slidingWindowCheck(identifier: string): Promise<boolean> {
  const now = Date.now()
  const windowStart = now - WINDOW_MS
  const key = `rl_slide:${identifier}`

  const pipe = redis.pipeline()
  pipe.zremrangebyscore(key, 0, windowStart)  // Remove old entries
  pipe.zadd(key, now, `${now}-${Math.random()}`)  // Add current request
  pipe.zcard(key)  // Count in window
  pipe.expire(key, 120)  // TTL for cleanup

  const results = await pipe.exec()
  const count = results![2][1] as number

  return count <= MAX_REQUESTS
}

Fix 5: Business Logic Rate Limits

// Some limits should be based on business logic, not just request count
// Because each "request" may represent wildly different computational cost

// Example: file export
router.post('/api/export',
  requireAuth,
  async (req, res) => {
    const { startDate, endDate, includeAttachments } = req.body

    // Calculate estimated "cost" of this export
    const exportDays = (new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)
    const estimatedRows = exportDays * 1000  // Rough estimate
    const cost = Math.ceil(estimatedRows / 10000) * (includeAttachments ? 5 : 1)

    // Check export credit (monthly budget of 100 credits)
    const creditResult = await checkQuota(req.user.id, 'export_credits', cost)
    if (!creditResult.allowed) {
      return res.status(429).json({
        error: `Export would cost ${cost} credits (${creditResult.remaining} remaining this month)`,
        upgradeUrl: '/pricing',
      })
    }

    // Proceed with export
    const jobId = await exportQueue.add({ userId: req.user.id, startDate, endDate, includeAttachments })
    res.json({ jobId })
  }
)

Multi-Dimensional Rate Limiting Checklist

  • ✅ Rate limiting by IP address (but not only IP)
  • ✅ Separate rate limits by authenticated user account
  • ✅ Device fingerprinting adds another dimension attackers can't easily rotate
  • ✅ IP extraction uses trusted proxy logic — can't be spoofed via X-Forwarded-For
  • ✅ Sliding window algorithm (no seam attack vulnerability)
  • ✅ Endpoint-specific limits for sensitive operations (login, signup, export)
  • ✅ Business logic rate limits where "requests" have variable cost
  • ✅ Rate limit headers returned so legitimate clients can back off gracefully

Conclusion

IP-only rate limiting is a speed bump for determined attackers. Effective rate limiting uses multiple dimensions that are difficult to rotate simultaneously: IP address, authenticated user account, and device fingerprint. The device fingerprint is the key insight — even when an attacker rotates through 1,000 IPs, their browser's JavaScript environment, header combination, and behavioral patterns often stay consistent. Combined with sliding window algorithms (no seam attacks), endpoint-specific limits for sensitive operations, and business-logic-based cost units for expensive endpoints, you create a rate limiting system that's genuinely difficult to bypass without looking expensive to try.