Security

Rate limiting with Nginx and Redis: protecting APIs at scale.

February 25, 2026 ยท11 min read ยท47Network Engineering

Rate limiting is the first line of defence against abuse, credential stuffing, and accidental DDoS from runaway clients. Done at the Nginx layer it's cheap โ€” a decision made before the request reaches your application, using a shared memory zone updated atomically. For distributed multi-instance deployments, Nginx's in-process counters aren't shared, so Redis becomes the authoritative rate limit store. This post covers both: Nginx limit_req for single-server scenarios, and a Redis token bucket implementation for multi-instance APIs.

Nginx limit_req: the simplest form

http {
  # Define a shared memory zone keyed by client IP
  # 10m = ~160,000 IP addresses
  limit_req_zone $binary_remote_addr zone=global:10m rate=10r/s;

  # Tighter zone for auth endpoints
  limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;

  # Per-user rate limiting (requires auth to set $user_id header upstream)
  map $http_x_user_id $limit_key {
    default $binary_remote_addr;  # Fall back to IP if no user header
    "~."    $http_x_user_id;      # Use user ID if present
  }
  limit_req_zone $limit_key zone=per_user:10m rate=100r/m;

  server {
    # Apply global limit โ€” burst of 20 with nodelay (serve bursts immediately)
    location /api/ {
      limit_req zone=global burst=20 nodelay;
      limit_req_status 429;
      add_header Retry-After 1 always;
      proxy_pass http://backend;
    }

    # Strict limit on login โ€” 5 attempts/minute, no burst
    location /api/auth/login {
      limit_req zone=auth burst=2;
      limit_req_status 429;
      add_header Retry-After 30 always;
      proxy_pass http://backend;
    }
  }
}

Redis sliding window rate limiting for distributed deployments

When your API runs on multiple instances, each Nginx has its own limit_req_zone โ€” a user can hit 10 req/s on each instance. For accurate distributed rate limiting, use Redis as the shared counter store. The sliding window algorithm using Redis's sorted sets is accurate and avoids the "boundary burst" problem of fixed windows:

-- rate_limit.lua (called via Nginx + lua-resty-redis, or from your app)
local redis = require "resty.redis"
local red = redis:new()
red:connect("127.0.0.1", 6379)

local key = "rate:" .. ngx.var.arg_user_id
local now = ngx.now() * 1000  -- milliseconds
local window = 60000           -- 60 second window
local limit = 100              -- 100 requests per 60 seconds

-- Remove entries older than the window
red:zremrangebyscore(key, 0, now - window)
-- Count requests in current window
local count = red:zcard(key)

if tonumber(count) >= limit then
  ngx.status = 429
  ngx.header["Retry-After"] = "60"
  ngx.header["X-RateLimit-Limit"] = limit
  ngx.header["X-RateLimit-Remaining"] = "0"
  ngx.say('{"error":"rate_limit_exceeded"}')
  return ngx.exit(429)
end

-- Record this request
red:zadd(key, now, now .. math.random())
red:expire(key, 61)  -- TTL slightly longer than window

ngx.header["X-RateLimit-Limit"] = limit
ngx.header["X-RateLimit-Remaining"] = limit - count - 1

Application-layer rate limiting with Redis (Node.js)

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

async function checkRateLimit(identifier: string, {
  limit = 100,
  windowSeconds = 60,
}: { limit?: number; windowSeconds?: number } = {}) {
  const key = `rate:${identifier}`;
  const now = Date.now();
  const windowMs = windowSeconds * 1000;

  const pipeline = redis.multi();
  pipeline.zRemRangeByScore(key, 0, now - windowMs);
  pipeline.zCard(key);
  pipeline.zAdd(key, { score: now, value: `${now}:${Math.random()}` });
  pipeline.expire(key, windowSeconds + 1);

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

  if (currentCount >= limit) {
    return {
      allowed: false,
      remaining: 0,
      resetAt: new Date(now + windowMs),
    };
  }

  return {
    allowed: true,
    remaining: limit - currentCount - 1,
    resetAt: new Date(now + windowMs),
  };
}

// Express middleware
export function rateLimitMiddleware(options = {}) {
  return async (req, res, next) => {
    // Rate limit by authenticated user ID, fall back to IP
    const identifier = req.user?.id ?? req.ip;
    const result = await checkRateLimit(identifier, options);

    res.set('X-RateLimit-Limit', String(options.limit ?? 100));
    res.set('X-RateLimit-Remaining', String(result.remaining));
    res.set('X-RateLimit-Reset', result.resetAt.toISOString());

    if (!result.allowed) {
      return res.status(429).json({
        error: 'rate_limit_exceeded',
        retryAfter: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000),
      });
    }
    next();
  };
}

Rate limiting strategy across 47Network products: Nginx handles IP-based rate limiting at the edge (protection from unauthenticated traffic), and Redis sliding window handles per-user limits at the application layer. The e-commerce zero-trust engagement uses aggressive rate limiting on the Keycloak auth endpoints โ€” 5 login attempts per minute per IP, with exponential backoff responses โ€” which reduced credential stuffing attempts from ~40/min to near zero within 24 hours of deployment.


โ† Back to Blog Nginx Guide โ†’