Backend & APIs • 14 min de leitura

Rate limiting em APIs: do conceito à implementação real

Rate limiting em APIs: do conceito à implementação real

Toda API exposta à internet está sujeita a abuso. Pode ser um bot tentando força bruta num endpoint de login, um script raspando dados sem parar, ou um ataque DDoS tentando derrubar o serviço. O rate limiting é a primeira linha de defesa contra todos esses cenários — e é mais simples de implementar do que parece.

Neste artigo vamos do conceito à implementação concreta, com exemplos funcionais em três camadas: Nginx, Node.js e PHP. Cada uma resolve um problema diferente, e o ideal é combiná-las.

O que é rate limiting e por que importa

Rate limiting é a prática de controlar quantas requisições um cliente pode fazer em um determinado período. "Cliente" pode ser um endereço IP, um usuário autenticado, uma chave de API ou qualquer identificador que faça sentido para o seu contexto.

Sem isso, qualquer endpoint pode ser:

  • Sobrecarregado por requisições legítimas ou maliciosas além da capacidade do servidor
  • Enumerado por bots testando credenciais em sequência (credential stuffing)
  • Raspado por scripts coletando dados sem controle
  • Abusado por clientes que consomem recursos desproporcionais

O rate limiting não substitui autenticação, WAF ou outras camadas de segurança — mas é insubstituível como controle de volume.

Estratégias de contagem

Antes do código, é preciso entender como o limite é contado. Existem quatro algoritmos principais:

Fixed window (janela fixa)

Conta requisições dentro de janelas de tempo fixas (ex: 0–60s, 60–120s). Simples de implementar, mas tem uma vulnerabilidade: um cliente pode fazer 100 requisições nos últimos segundos de uma janela e 100 nos primeiros da próxima, totalizando 200 em 2 segundos sem violar a regra.

Janela 1: [0s -------- 60s]  → 100 req ✓
Janela 2: [60s ------- 120s] → 100 req ✓
Mas: 50 req em t=58s + 50 req em t=62s = 100 req em 4s → passou dos limites reais

Sliding window (janela deslizante)

Conta as requisições dos últimos N segundos a partir do momento atual. Mais preciso que a janela fixa, sem a vulnerabilidade da virada de janela. Requer mais memória (precisa guardar timestamps).

Token bucket (balde de tokens)

O cliente tem um "balde" com N tokens. Cada requisição consome um token. Os tokens são reabastecidos a uma taxa constante. Permite bursts controlados — um cliente inativo acumula tokens e pode fazer várias requisições seguidas, mas não indefinidamente.

Leaky bucket (balde furado)

As requisições entram no balde e saem a uma taxa constante. Bursts são absorvidos pelo balde, mas o processamento é sempre uniforme. Útil quando você quer garantir throughput consistente no backend.

Para a maioria das APIs, sliding window ou token bucket são as melhores escolhas.

Camada 1: Rate limiting no Nginx

O Nginx tem um módulo nativo de rate limiting (ngx_http_limit_req_module) que opera antes mesmo da requisição chegar à aplicação. É a camada mais eficiente porque descarta requisições excessivas sem custo de processamento no backend.

Configuração básica

# /etc/nginx/nginx.conf ou no bloco http do seu site

http {

    # Define uma zona de memória compartilhada para rastrear IPs
    # "api_limit" = nome da zona
    # $binary_remote_addr = chave (IP do cliente em formato binário, menor que $remote_addr)
    # zone=api_limit:10m = 10MB de memória (~160.000 IPs)
    # rate=10r/s = máximo 10 requisições por segundo por IP
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # Zona separada para endpoints de login (mais restritiva)
    limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;

    server {
        listen 443 ssl;
        server_name api.meusite.com;

        # Aplica o limite em todas as rotas da API
        location /api/ {
            # burst=20: permite fila de até 20 requisições acima do limite
            # nodelay: processa o burst imediatamente (sem atraso artificial)
            limit_req zone=api_limit burst=20 nodelay;

            # Retorna 429 em vez de 503 (semântica mais correta para rate limiting)
            limit_req_status 429;

            proxy_pass http://backend;
        }

        # Endpoint de login com limite muito mais baixo
        location /api/auth/login {
            limit_req zone=login_limit burst=3 nodelay;
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

Limitando por usuário autenticado em vez de IP

Limitar por IP tem um problema: usuários atrás de NAT ou VPN compartilham o mesmo IP. Se você tem autenticação JWT ou similar, use o identificador do usuário:

http {
    # Usa o header Authorization como chave (para APIs com token)
    # Se não houver header, cai para o IP
    map $http_authorization $rate_limit_key {
        default $binary_remote_addr;
        ~.+     $http_authorization;
    }

    limit_req_zone $rate_limit_key zone=api_user_limit:20m rate=30r/s;
}

Adicionando headers de resposta informativos

O padrão de mercado é incluir headers que informam o cliente sobre o limite:

location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    limit_req_status 429;

    # Headers informativos (os valores são estáticos aqui; para dinâmicos, use a aplicação)
    add_header X-RateLimit-Limit 10;
    add_header Retry-After 60;

    proxy_pass http://backend;
}

Testando a configuração

# Simula 50 requisições em paralelo e mostra os status codes
for i in $(seq 1 50); do
  curl -s -o /dev/null -w "%{http_code}\n" https://api.meusite.com/api/dados &
done
wait

# Saída esperada: vários 200, depois 429

Camada 2: Rate limiting em Node.js

Para rate limiting dentro da aplicação Node.js, a combinação mais robusta para produção é express-rate-limit com Redis como store. O Redis garante que o limite funciona corretamente em ambientes com múltiplas instâncias (vários pods no Kubernetes, múltiplos processos PM2, etc.).

Instalação

npm install express-rate-limit @express-rate-limit/redis ioredis

Configuração com Redis

// src/middleware/rateLimiter.js
const { rateLimit } = require('express-rate-limit');
const { RedisStore } = require('@express-rate-limit/redis');
const Redis = require('ioredis');

// Conexão com Redis
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASSWORD,
  // Reconexão automática
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

redis.on('error', (err) => {
  console.error('[Redis] Erro de conexão:', err.message);
});

// Limiter geral para a API
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,        // janela de 1 minuto
  max: 100,                    // máximo de 100 requisições por janela
  standardHeaders: 'draft-7', // inclui headers RateLimit-* na resposta
  legacyHeaders: false,        // desativa headers X-RateLimit-* antigos

  // Usa Redis como store (funciona em múltiplas instâncias)
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),

  // Identifica o cliente pelo IP (veja abaixo para alternativas)
  keyGenerator: (req) => {
    // Respeita o header X-Forwarded-For quando atrás de proxy/load balancer
    return req.ip || req.headers['x-forwarded-for']?.split(',')[0].trim();
  },

  // Mensagem de erro padronizada
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too Many Requests',
      message: 'Você excedeu o limite de requisições. Tente novamente em breve.',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});

// Limiter estrito para autenticação (previne força bruta)
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // janela de 15 minutos
  max: 10,                    // máximo de 10 tentativas
  standardHeaders: 'draft-7',
  legacyHeaders: false,

  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),

  // Para login, identifica pelo IP + email (evita que um IP bloqueado
  // afete outros usuários; evita que um e-mail seja atacado de vários IPs)
  keyGenerator: (req) => {
    const ip = req.ip || req.headers['x-forwarded-for']?.split(',')[0].trim();
    const email = req.body?.email?.toLowerCase() || '';
    return `auth:${ip}:${email}`;
  },

  skipSuccessfulRequests: true, // não conta logins bem-sucedidos no limite

  handler: (req, res) => {
    console.warn(`[RateLimit] Bloqueio em auth para IP: ${req.ip}, email: ${req.body?.email}`);
    res.status(429).json({
      error: 'Too Many Requests',
      message: 'Muitas tentativas de login. Tente novamente em 15 minutos.',
    });
  },
});

// Limiter por usuário autenticado (após middleware de autenticação)
const userLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 200,
  standardHeaders: 'draft-7',
  legacyHeaders: false,

  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),

  // Usa o ID do usuário autenticado como chave
  keyGenerator: (req) => {
    return req.user?.id
      ? `user:${req.user.id}`
      : req.ip;
  },
});

module.exports = { apiLimiter, authLimiter, userLimiter };

Aplicando os limiters nas rotas

// src/app.js
const express = require('express');
const { apiLimiter, authLimiter, userLimiter } = require('./middleware/rateLimiter');
const authMiddleware = require('./middleware/auth');

const app = express();
app.use(express.json());

// Configuração importante: informa ao Express para confiar no proxy
// Necessário para que req.ip retorne o IP real do cliente (não o do load balancer)
app.set('trust proxy', 1);

// Limiter geral em todas as rotas /api
app.use('/api', apiLimiter);

// Limiter estrito apenas no login
app.post('/api/auth/login', authLimiter, async (req, res) => {
  // lógica de login
});

// Limiter por usuário em rotas autenticadas
app.use('/api/v1', authMiddleware, userLimiter);

app.get('/api/v1/dados', (req, res) => {
  res.json({ dados: [] });
});

Implementação manual com Redis (sem biblioteca)

Às vezes você precisa de um algoritmo específico ou está em um contexto sem Express. Aqui está uma implementação de sliding window com Redis puro:

// src/utils/slidingWindowLimiter.js
const Redis = require('ioredis');
const redis = new Redis({ host: process.env.REDIS_HOST || 'localhost' });

/**
 * Verifica e registra uma requisição usando sliding window com Redis.
 *
 * @param {string} key        - Identificador do cliente (IP, user ID, etc.)
 * @param {number} maxRequests - Número máximo de requisições permitidas
 * @param {number} windowMs   - Tamanho da janela em milissegundos
 * @returns {{ allowed: boolean, remaining: number, resetAt: number }}
 */
async function slidingWindowLimit(key, maxRequests, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;
  const redisKey = `ratelimit:${key}`;

  // Pipeline: executa os comandos atomicamente
  const pipeline = redis.pipeline();

  // Remove entradas fora da janela
  pipeline.zremrangebyscore(redisKey, '-inf', windowStart);

  // Conta as entradas restantes
  pipeline.zcard(redisKey);

  // Adiciona a requisição atual (score = timestamp, member = timestamp+random para unicidade)
  pipeline.zadd(redisKey, now, `${now}-${Math.random()}`);

  // Define TTL para limpeza automática
  pipeline.pexpire(redisKey, windowMs);

  const results = await pipeline.exec();
  const currentCount = results[1][1]; // resultado do zcard

  if (currentCount >= maxRequests) {
    // Busca o timestamp mais antigo para calcular quando o limite vai expirar
    const oldest = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
    const resetAt = oldest[1] ? parseInt(oldest[1]) + windowMs : now + windowMs;

    return { allowed: false, remaining: 0, resetAt };
  }

  return {
    allowed: true,
    remaining: maxRequests - currentCount - 1,
    resetAt: now + windowMs,
  };
}

module.exports = { slidingWindowLimit };
// Usando o limiter manual como middleware Express
const { slidingWindowLimit } = require('./utils/slidingWindowLimiter');

function createRateLimitMiddleware(maxRequests, windowMs) {
  return async (req, res, next) => {
    const key = req.user?.id || req.ip;

    try {
      const result = await slidingWindowLimit(key, maxRequests, windowMs);

      // Adiciona headers informativos
      res.set({
        'RateLimit-Limit': maxRequests,
        'RateLimit-Remaining': result.remaining,
        'RateLimit-Reset': Math.ceil(result.resetAt / 1000),
      });

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

      next();
    } catch (err) {
      // Se o Redis cair, deixa passar (fail open) ou bloqueia tudo (fail closed)
      // Fail open é mais adequado para produção: evita derrubar o serviço por falha do Redis
      console.error('[RateLimit] Erro ao verificar limite:', err.message);
      next();
    }
  };
}

module.exports = { createRateLimitMiddleware };

---

Camada 3: Rate limiting em PHP

Com Redis (recomendado para produção)

<?php
// src/Middleware/RateLimiter.php

class RateLimiter
{
    private Redis $redis;
    private int $maxRequests;
    private int $windowSeconds;

    public function __construct(Redis $redis, int $maxRequests = 100, int $windowSeconds = 60)
    {
        $this->redis = $redis;
        $this->maxRequests = $maxRequests;
        $this->windowSeconds = $windowSeconds;
    }

    /**
     * Verifica se o cliente pode fazer a requisição.
     * Usa o algoritmo de sliding window com sorted sets do Redis.
     */
    public function check(string $identifier): array
    {
        $now = (int)(microtime(true) * 1000); // timestamp em ms
        $windowStart = $now - ($this->windowSeconds * 1000);
        $key = "ratelimit:{$identifier}";

        // Executa como transação Redis para atomicidade
        $this->redis->multi();

        // Remove entradas fora da janela
        $this->redis->zRemRangeByScore($key, '-inf', $windowStart);

        // Conta as entradas restantes
        $this->redis->zCard($key);

        // Adiciona a requisição atual
        $this->redis->zAdd($key, $now, "{$now}-" . uniqid());

        // Define TTL
        $this->redis->expire($key, $this->windowSeconds);

        $results = $this->redis->exec();
        $currentCount = $results[1]; // resultado do zCard

        if ($currentCount >= $this->maxRequests) {
            // Calcula quando o limite vai resetar
            $oldest = $this->redis->zRange($key, 0, 0, true);
            $oldestScore = !empty($oldest) ? (int)array_values($oldest)[0] : $now;
            $resetAt = (int)(($oldestScore + $this->windowSeconds * 1000) / 1000);

            return [
                'allowed'   => false,
                'remaining' => 0,
                'limit'     => $this->maxRequests,
                'reset_at'  => $resetAt,
                'retry_after' => $resetAt - (int)(time()),
            ];
        }

        return [
            'allowed'   => true,
            'remaining' => $this->maxRequests - $currentCount - 1,
            'limit'     => $this->maxRequests,
            'reset_at'  => (int)(time()) + $this->windowSeconds,
            'retry_after' => 0,
        ];
    }
}
<?php
// Usando o RateLimiter em um controller ou middleware

function applyRateLimit(string $identifier, RateLimiter $limiter): void
{
    $result = $limiter->check($identifier);

    // Sempre envia os headers informativos
    header("RateLimit-Limit: {$result['limit']}");
    header("RateLimit-Remaining: {$result['remaining']}");
    header("RateLimit-Reset: {$result['reset_at']}");

    if (!$result['allowed']) {
        header("Retry-After: {$result['retry_after']}");
        http_response_code(429);
        header('Content-Type: application/json');
        echo json_encode([
            'error'       => 'Too Many Requests',
            'message'     => 'Limite de requisições excedido.',
            'retry_after' => $result['retry_after'],
        ]);
        exit;
    }
}

// Identificação do cliente
// Prefira o ID do usuário autenticado quando disponível
$clientId = $_SESSION['user_id'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];
// Normaliza IPs múltiplos do X-Forwarded-For
if (str_contains($clientId, ',')) {
    $clientId = trim(explode(',', $clientId)[0]);
}

$redis = new Redis();
$redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', 6379);

// Limiter geral: 100 req/min
$limiter = new RateLimiter($redis, maxRequests: 100, windowSeconds: 60);
applyRateLimit($clientId, $limiter);

// Para endpoints de login: 5 tentativas a cada 15 minutos
// $authLimiter = new RateLimiter($redis, maxRequests: 5, windowSeconds: 900);
// applyRateLimit("auth:{$clientId}", $authLimiter);

Integração com Laravel

Se você usa Laravel, a implementação é ainda mais simples:

<?php
// app/Http/Middleware/ApiRateLimiter.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ApiRateLimiter
{
    public function __construct(private RateLimiter $limiter) {}

    public function handle(Request $request, Closure $next, int $maxAttempts = 100, int $decayMinutes = 1): Response
    {
        // Usa o ID do usuário autenticado ou o IP como chave
        $key = $request->user()?->id
            ? 'user:' . $request->user()->id
            : 'ip:' . $request->ip();

        if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
            $seconds = $this->limiter->availableIn($key);

            return response()->json([
                'error'       => 'Too Many Requests',
                'retry_after' => $seconds,
            ], 429, [
                'Retry-After'       => $seconds,
                'RateLimit-Limit'   => $maxAttempts,
                'RateLimit-Remaining' => 0,
            ]);
        }

        $this->limiter->hit($key, $decayMinutes * 60);

        $response = $next($request);

        $remaining = $maxAttempts - $this->limiter->attempts($key);

        return $response->withHeaders([
            'RateLimit-Limit'     => $maxAttempts,
            'RateLimit-Remaining' => max(0, $remaining),
        ]);
    }
}
// routes/api.php — aplicando o middleware nas rotas

Route::middleware(['throttle:100,1'])->group(function () {
    Route::get('/dados', [DadosController::class, 'index']);
});

// Ou com o middleware customizado:
Route::middleware([ApiRateLimiter::class . ':100,1'])->group(function () {
    Route::apiResource('produtos', ProdutoController::class);
});

// Endpoint de login com limite mais restritivo
Route::post('/auth/login', [AuthController::class, 'login'])
    ->middleware([ApiRateLimiter::class . ':10,15']); // 10 tentativas a cada 15 min

Boas práticas e armadilhas comuns

Sempre use Redis (ou equivalente) em produção

Armazenar contadores em memória local (array, APCu, memória do processo) quebra em qualquer ambiente com mais de uma instância. Se você tem dois servidores ou dois processos Node com PM2, cada um tem seu próprio contador — e o limite efetivo dobra. Use Redis, Memcached ou outro store centralizado.

Não confie apenas no IP

IPs podem ser compartilhados por:

  • Usuários em redes corporativas ou universitárias (todos têm o mesmo IP público)
  • VPNs e proxies
  • CGNAT de operadoras móveis (milhares de usuários no mesmo IP)

Para APIs autenticadas, use o ID do usuário como chave principal. Para APIs públicas, combine IP com outros fatores (user-agent, fingerprint, etc.) quando possível.

Diferencie os limites por endpoint

Não aplique o mesmo limite para tudo. Um endpoint de busca pode tolerar 100 req/min; um endpoint de login deve aceitar no máximo 5–10 tentativas em 15 minutos. Um endpoint de envio de e-mail pode ter limite de 3 por hora.

Implemente fail open com cautela

Se o Redis cair, você tem duas opções:

  • Fail open: deixa todas as requisições passar (prioriza disponibilidade)
  • Fail closed: bloqueia tudo até o Redis voltar (prioriza segurança)

Para a maioria das APIs, fail open é o certo — você não quer derrubar seu serviço porque o Redis teve um problema de conexão de 5 segundos. Mas para endpoints críticos (pagamento, autenticação), considere fail closed ou um fallback em memória local como medida temporária.

Retorne headers padronizados

Os headers RateLimit-* estão em processo de padronização pelo IETF (RFC draft). Use-os para que os clientes possam se adaptar automaticamente:

RateLimit-Limit: 100
RateLimit-Remaining: 73
RateLimit-Reset: 1714000800
Retry-After: 42

O Retry-After é especialmente importante: diz ao cliente exatamente quando pode tentar novamente, evitando que ele faça polling desnecessário.

Monitore e ajuste os limites

Rate limiting sem monitoramento é cego. Registre nos logs:

  • Quantas requisições foram bloqueadas por período
  • Quais IPs ou usuários são bloqueados com frequência
  • Se usuários legítimos estão sendo afetados (falsos positivos)

Com essas métricas você pode calibrar os limites sem prejudicar usuários reais.

Referências

Tags: rate-limiting, nginx, nodejs, php, segurança, apis, redis