Skip to content

Rate Limiting and DoS Protection

Your app without rate limiting is a public API to anyone with a loop Script kiddies will brute force login endpoints , scrapers will drain your database connections , and if you're not rate limiting you're essentially inviting abuse Rate limiting is not optional security - it's survival

express-rate-limit Basics

const rateLimit = require('express-rate-limit')

// Basic limiter - 100 requests per 15 minutes
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // limit each IP
  standardHeaders: true,       // Return rate limit info in headers
  legacyHeaders: false,        // Disable X-RateLimit-* headers
  message: {
    error: 'too many requests',
    retryAfter: '15 minutes'
  }
})

// Apply to all routes
app.use(limiter)

// Per-endpoint limiter - stricter for auth
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'too many login attempts' },
  skipSuccessfulRequests: true,  // only count failures
  standardHeaders: true,
  legacyHeaders: false
})

app.post('/api/login', loginLimiter, async (req, res) => {
  // login logic
})

Always apply rate limiting globally first : then tighten specific endpoints (login , signup , password reset) with stricter limits

Rate Limiting Headers

// express-rate-limit with standard headers enabled
const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false
})

// Response headers:
// RateLimit-Limit: 10
// RateLimit-Remaining: 3
// RateLimit-Reset: 1620000000

// Client side: respect the Retry-After header on 429
fetch('/api/endpoint')
  .then(res => {
    if (res.status === 429) {
      const retryAfter = res.headers.get('Retry-After')
      console.log(`Back off for ${retryAfter} seconds`)
    }
  })

Rate limiting headers let clients self-regulate : they don't prevent abuse but they reduce unnecessary requests from well-behaved clients

Rate Limiting Strategies

// Token Bucket - fixed capacity , refills over time
class TokenBucket {
  constructor(capacity, refillRate, refillInterval) {
    this.capacity = capacity
    this.tokens = capacity
    this.refillRate = refillRate
    this.refillInterval = refillInterval
    this.lastRefill = Date.now()
  }

  consume(count = 1) {
    this._refill()
    if (this.tokens >= count) {
      this.tokens -= count
      return true
    }
    return false
  }

  _refill() {
    const now = Date.now()
    const elapsed = now - this.lastRefill
    const refillTokens = Math.floor(
      (elapsed / this.refillInterval) * this.refillRate
    )
    this.tokens = Math.min(this.capacity, this.tokens + refillTokens)
    this.lastRefill = now
  }
}

// Usage
const bucket = new TokenBucket(100, 10, 1000)  // 100 cap , 10/sec refill
if (bucket.consume()) {
  // process request
} else {
  // rate limited
}
// Sliding Window - track requests in a moving time window
class SlidingWindow {
  constructor(windowMs, maxRequests) {
    this.windowMs = windowMs
    this.maxRequests = maxRequests
    this.requests = new Map()  // ip -> [timestamps]
  }

  isAllowed(ip) {
    const now = Date.now()
    const cutoff = now - this.windowMs

    if (!this.requests.has(ip)) {
      this.requests.set(ip, [])
    }

    let timestamps = this.requests.get(ip)
    // Remove old entries
    timestamps = timestamps.filter(t => t > cutoff)
    this.requests.set(ip, timestamps)

    if (timestamps.length >= this.maxRequests) {
      return false
    }

    timestamps.push(now)
    return true
  }
}

Sliding window is more accurate than fixed window : fixed window can burst at the boundary (60 requests at 59:59 then 60 more at 60:01)

Distributed Rate Limiting with Redis

When you have multiple Node processes behind a load balancer , memory-based rate limiting is useless - each process has its own counter An attacker can bypass the limit by hitting different processes

const rateLimit = require('express-rate-limit')
const RedisStore = require('rate-limit-redis')
const { createClient } = require('redis')

const redisClient = createClient({
  url: process.env.REDIS_URL,
  enableOfflineQueue: false
})

// Handle Redis connection errors gracefully
redisClient.on('error', (err) => {
  console.error('Redis rate limit error:', err.message)
})

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
    prefix: 'ratelimit:'
  }),
  standardHeaders: true,
  legacyHeaders: false
})

app.use(limiter)
// Custom Lua script for atomic rate limiting in Redis
const rateLimitScript = `
  local key = KEYS[1]
  local limit = tonumber(ARGV[1])
  local window = tonumber(ARGV[2])
  local now = tonumber(ARGV[3])

  redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
  local count = redis.call('ZCARD', key)

  if count < limit then
    redis.call('ZADD', key, now, now .. ':' .. math.random())
    redis.call('EXPIRE', key, math.ceil(window / 1000))
    return 1
  end
  return 0
`

async function checkRateLimit(ip) {
  const allowed = await redisClient.eval(rateLimitScript, {
    keys: [`ratelimit:${ip}`],
    arguments: ['100', '60000', String(Date.now())]
  })
  return allowed === 1
}

Redis-based rate limiting is essential for production : without it , a distributed brute force with 10 rotating IPs bypasses your limit by 10x

Securing Against Rate Limit Bypasses

// DON'T trust X-Forwarded-For blindly - it's user-controlled
router.use(rateLimit({
  keyGenerator: (req) => {
    // Use a combination of factors
    const forwarded = req.headers['x-forwarded-for']
    const ip = forwarded
      ? forwarded.split(',')[0].trim()
      : req.ip

    // Add user agent to prevent IP rotation bypass
    const ua = req.headers['user-agent'] || 'unknown'
    return require('crypto')
      .createHash('md5')
      .update(ip + ua)
      .digest('hex')
  },
  max: 100
}))

Rate limit by user ID for authenticated endpoints : IP-based limits can be bypassed with botnets , but user ID-based limits hold even across IP changes

Custom Rate Limit Middleware

function createRateLimiter(name, { windowMs, max, keyFn }) {
  const store = new Map()

  // Periodic cleanup
  setInterval(() => {
    const cutoff = Date.now() - windowMs
    for (const [key, timestamps] of store) {
      const valid = timestamps.filter(t => t > cutoff)
      if (valid.length === 0) {
        store.delete(key)
      } else {
        store.set(key, valid)
      }
    }
  }, windowMs)

  return (req, res, next) => {
    const key = keyFn(req)
    const now = Date.now()
    const cutoff = now - windowMs

    if (!store.has(key)) {
      store.set(key, [])
    }

    const timestamps = store.get(key).filter(t => t > cutoff)
    store.set(key, timestamps)

    if (timestamps.length >= max) {
      res.set('Retry-After', Math.ceil(windowMs / 1000))
      return res.status(429).json({
        error: 'rate limit exceeded',
        name,
        retryAfter: `${Math.ceil(windowMs / 1000)} seconds`
      })
    }

    timestamps.push(now)
    next()
  }
}

app.use('/api/search', createRateLimiter('search', {
  windowMs: 60000,
  max: 30,
  keyFn: (req) => req.ip
}))

Prerequisites

next -> sec_07_dependency_audit.md