- Published on
API Rate Limit Exploited — When Your Limits Are Too Easy to Bypass
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Fix 1: Multi-Dimensional Rate Limiting
- Fix 2: Trust the Right IP Address
- Fix 3: Device Fingerprinting Beyond IP
- Fix 4: Sliding Window vs Fixed Window
- Fix 5: Business Logic Rate Limits
- Multi-Dimensional Rate Limiting Checklist
- Conclusion
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.