Rate limiting em Node.js: como proteger sua API contra abuso e bots

Implementar rate limiting em Node.js é uma das proteções mais baratas e eficazes que uma API pode ter. Sem limite de requisições, um único cliente mal configurado (ou um bot mal-intencionado) consegue derrubar o serviço, esgotar a cota de uma API paga que você consome ou disparar milhares de SMS às suas custas.

Este guia cobre a teoria necessária (os algoritmos de janela fixa, janela deslizante e token bucket), a implementação com express-rate-limit, como definir limites por IP, por usuário e por rota, como compartilhar os contadores entre várias instâncias com Redis e como responder corretamente com 429 Too Many Requests e Retry-After.

Por que limitar requisições: custo, abuso e estabilidade

Três motivos justificam o rate limit em praticamente qualquer API pública ou semipública:

  • Estabilidade: um pico de requisições, mesmo legítimo, ocupa conexões de banco, memória e CPU. Limitar por cliente evita que um só consumidor degrade o serviço para todos.
  • Custo: endpoints que chamam serviços pagos (envio de SMS, consultas a APIs de terceiros, modelos de IA) transformam cada requisição em dinheiro. Sem limite, a conta chega sem aviso.
  • Abuso: força bruta em login, enumeração de usuários, scraping e fraudes automatizadas dependem de volume. Reduzir o volume reduz o ataque.

Um caso clássico do terceiro grupo é o SMS pumping: bots disparam o endpoint de “enviar código de verificação” milhares de vezes para números premium, e o dono da aplicação paga pelo tráfego. O limite por IP, por telefone de destino e por conta é a primeira barreira contra esse golpe, antes de qualquer análise mais sofisticada.

Algoritmos: fixed window, sliding window e token bucket

Todo rate limiter responde à mesma pergunta: “este cliente ainda pode fazer uma requisição agora?”. O que muda é como ele conta. Conhecer os três algoritmos principais ajuda a escolher a biblioteca e a entender os parâmetros.

Janela fixa (fixed window)

Divide o tempo em blocos (por exemplo, minutos cheios) e conta as requisições de cada bloco. É o mais simples e barato, mas tem um problema na fronteira: um cliente pode usar o limite inteiro no fim de uma janela e de novo no início da seguinte, dobrando a taxa real por alguns segundos.

Janela deslizante (sliding window)

Conta as requisições dos últimos N segundos a partir de agora, em vez de a partir de um marco fixo. Elimina o problema da fronteira ao custo de mais memória (guardar timestamps) ou de uma aproximação ponderada entre a janela atual e a anterior. É o que a maioria das implementações com Redis usa.

Token bucket

Imagine um balde com capacidade para N fichas, reabastecido a uma taxa constante. Cada requisição consome uma ficha; sem fichas, a requisição é rejeitada. O token bucket permite rajadas curtas (até a capacidade do balde) enquanto mantém a média controlada, o que combina com o comportamento de clientes reais.

// Token bucket em memória, para entender o mecanismo
class TokenBucket {
  constructor(capacidade, fichasPorSegundo) {
    this.capacidade = capacidade;
    this.taxa = fichasPorSegundo;
    this.fichas = capacidade;
    this.ultimo = Date.now();
  }

  consumir() {
    const agora = Date.now();
    this.fichas = Math.min(this.capacidade, this.fichas + ((agora - this.ultimo) / 1000) * this.taxa);
    this.ultimo = agora;
    if (this.fichas < 1) return false;
    this.fichas -= 1;
    return true;
  }
}

const balde = new TokenBucket(10, 2); // rajada de 10, média de 2 req/s
AlgoritmoPrecisãoMemóriaRajadasQuando usar
Janela fixaBaixa na fronteiraUm contador por chaveAté 2x no limiteLimites simples, baixo custo
Janela deslizanteAltaTimestamps ou dois contadoresControladasAPIs públicas, Redis
Token bucketAlta na médiaDois números por chavePermitidas até a capacidadeClientes com picos naturais
Infográfico com a estratégia de rate limiting em Node.js: limite global e por rota sensível, chave por usuário autenticado, Redis para múltiplas instâncias, resposta 429 com Retry-After e monitoramento para ajustes

Rate limiting em Node.js com express-rate-limit

Para APIs em Express, o express-rate-limit é o padrão de mercado: um middleware que conta requisições por chave (por padrão, o IP) dentro de uma janela e devolve 429 quando o limite estoura. A instalação e o uso básico cabem em poucas linhas:

npm install express express-rate-limit
import express from 'express';
import { rateLimit } from 'express-rate-limit';

const app = express();

// Atrás de Nginx, Cloudflare ou load balancer: confie no primeiro proxy
// para que req.ip seja o IP real do cliente, e não o do proxy.
app.set('trust proxy', 1);

const limiteGlobal = rateLimit({
  windowMs: 15 * 60 * 1000,   // janela de 15 minutos
  limit: 300,                 // 300 requisições por janela, por IP
  standardHeaders: 'draft-7', // envia os cabeçalhos RateLimit-*
  legacyHeaders: false,       // desliga os antigos X-RateLimit-*
  message: { error: true, message: 'Muitas requisições. Aguarde e tente novamente.', data: null },
});

app.use(limiteGlobal);

app.get('/api/ping', (req, res) => res.json({ error: false, message: 'ok', data: null }));

app.listen(3000);

Dois detalhes evitam bugs em produção. O primeiro é o trust proxy: sem ele, todas as requisições que passam por um proxy reverso chegam com o mesmo IP e um único cliente bloqueia todo mundo. O segundo é que o armazenamento padrão fica na memória do processo; serve para desenvolvimento e para uma instância só, mas não para um cluster, como veremos adiante.

Limites por IP, por usuário e por rota

Um limite global por IP é o mínimo, mas não é suficiente. Rotas de login, cadastro, recuperação de senha e envio de código merecem limites bem mais restritos, e usuários autenticados devem ser contados pela identidade, não pelo IP (várias pessoas atrás do mesmo NAT corporativo compartilham o endereço).

import { rateLimit, ipKeyGenerator } from 'express-rate-limit';

// Chave: id do usuário autenticado; IP (com tratamento de IPv6) para anônimos
const chavePorUsuario = (req) => (req.user?.id ? `user:${req.user.id}` : ipKeyGenerator(req.ip));

const limiteLogin = rateLimit({
  windowMs: 10 * 60 * 1000,
  limit: 10,
  keyGenerator: chavePorUsuario,
  skipSuccessfulRequests: true, // só conta tentativas que falharam
  standardHeaders: 'draft-7',
  legacyHeaders: false,
});

// Envio de código por SMS/WhatsApp: por telefone de destino, independente do IP
const limiteEnvioCodigo = rateLimit({
  windowMs: 60 * 60 * 1000,
  limit: 5,
  keyGenerator: (req) => {
    const telefone = String(req.body?.telefone ?? '').replace(/\D/g, '');
    return telefone ? `tel:${telefone}` : ipKeyGenerator(req.ip);
  },
  standardHeaders: 'draft-7',
  legacyHeaders: false,
});

app.use(express.json()); // precisa vir antes de qualquer limiter que leia req.body
app.post('/auth/login', limiteLogin, login);
app.post('/auth/codigo', limiteGlobal, limiteEnvioCodigo, enviarCodigo);

A estratégia que costuma funcionar é em camadas: um limite global generoso para absorver scraping grosseiro, um limite por rota sensível bem apertado e, para quem está autenticado, uma chave por usuário ou por chave de API. Para endpoints de envio (SMS, e-mail, WhatsApp), vale somar uma terceira dimensão: o destino. Mesmo que o atacante troque de IP, o número de telefone alvo continua limitado.

Os tokens que esses endpoints usam para chamar serviços externos devem ficar em variáveis de ambiente, nunca no código. O artigo sobre segurança de tokens com variáveis de ambiente e cofres de senhas mostra como organizar isso.

Rate limit distribuído com Redis

Assim que a API roda em mais de um processo (PM2 em modo cluster, vários containers, autoscaling), o contador em memória deixa de valer: cada instância conta separadamente e o limite real vira o limite multiplicado pelo número de instâncias. A solução é um armazenamento compartilhado, e o Redis é o mais usado por ser rápido e ter operações atômicas de incremento com expiração.

npm install redis rate-limit-redis
# Redis local para desenvolvimento
docker run -d --name redis -p 6379:6379 redis:7-alpine
import { createClient } from 'redis';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';

const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('Redis:', err.message));
await redis.connect();

const limiteDistribuido = rateLimit({
  windowMs: 60 * 1000,
  limit: 60,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redis.sendCommand(args),
    prefix: 'rl:', // prefixo das chaves no Redis
  }),
});

app.use('/api', limiteDistribuido);

Com o store no Redis, todas as instâncias enxergam o mesmo contador por chave, e a expiração da janela é controlada pelo próprio Redis. Se o Redis cair, decida o comportamento com antecedência: a maioria dos times prefere deixar passar (fail open) e registrar o erro, em vez de derrubar a API inteira por causa do limiter. Para subir o Redis e a API juntos em desenvolvimento, o guia prático de Docker mostra o básico de containers e redes.

Respondendo 429 e cabeçalhos Retry-After

Bloquear é metade do trabalho; a outra metade é dizer ao cliente quando voltar. A resposta certa é o status 429 Too Many Requests, acompanhado do cabeçalho Retry-After (em segundos) e, com standardHeaders ativo, dos cabeçalhos RateLimit e RateLimit-Policy, que informam limite, restante e tempo até o reset. Clientes bem escritos leem esses valores e esperam, em vez de martelar o endpoint.

const limiteComHandler = rateLimit({
  windowMs: 60 * 1000,
  limit: 30,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  handler: (req, res, next, options) => {
    // o Retry-After já é enviado pela biblioteca; aqui só padronizamos o corpo
    const segundos = Math.ceil(options.windowMs / 1000);
    res.status(options.statusCode).json({
      error: true,
      message: `Limite de requisições atingido. Tente novamente em até ${segundos} segundos.`,
      data: { retryAfter: segundos },
    });
  },
});

Do lado de quem consome uma API, o mesmo raciocínio vale ao contrário: ao receber 429 de um serviço de terceiros, respeite o Retry-After e use backoff exponencial em vez de tentar de novo imediatamente. É uma das boas práticas para integrar APIs em produção e evita que a sua aplicação seja bloqueada por abuso do lado do fornecedor.

// Cliente que respeita 429 + Retry-After com backoff exponencial
export async function fetchComRetry(url, options = {}, tentativas = 4) {
  for (let i = 0; i < tentativas; i++) {
    const res = await fetch(url, options);
    if (res.status !== 429) return res;

    const retryAfter = Number(res.headers.get('retry-after'));
    const espera = (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** i) * 1000;
    await new Promise((r) => setTimeout(r, espera));
  }
  throw new Error(`Limite de requisições persistente em ${url}`);
}

Por fim, monitore. Registre quantos 429 a API devolve por rota e por chave: muitos bloqueios de usuários legítimos indicam limite apertado demais; nenhum bloqueio em uma rota de login indica que ele está frouxo ou mal configurado (quase sempre, o trust proxy).

Perguntas frequentes

Rate limiting substitui autenticação ou WAF?

Não. O rate limit controla volume, não identidade nem conteúdo. Ele complementa autenticação, validação de entrada e um firewall de aplicação. Pense nele como a camada que impede que um problema pequeno vire um problema de escala.

Qual limite devo usar na minha API?

Não existe número universal. Meça o uso real por rota durante alguns dias, defina o limite global com folga acima do pico legítimo e aperte só as rotas sensíveis (login, cadastro, envio de código). Ajuste com base nos 429 registrados.

Preciso de Redis para começar com rate limiting em Node.js?

Não. Com uma única instância, o armazenamento em memória do express-rate-limit funciona bem. Redis entra quando a API roda em mais de um processo ou servidor, ou quando você quer que os contadores sobrevivam a um restart.

Como limitar por chave de API em vez de por IP?

Use a opção keyGenerator para devolver a chave de API (ou o id do cliente) lida do cabeçalho de autorização. Assim cada cliente tem o próprio contador, independentemente de quantos IPs usa, e você pode ter limites diferentes por plano.

O que fazer com bots que trocam de IP o tempo todo?

Combine dimensões: além do IP, limite por conta, por telefone ou e-mail de destino e por fingerprint da requisição. Para endpoints de envio, adicione um desafio (captcha ou verificação prévia) acima de certo volume e monitore padrões, como muitos números de uma mesma faixa.

Próximos passos

Adicione o limite global hoje, com trust proxy configurado, e registre os 429. Na sequência, identifique as três rotas mais sensíveis e dê a cada uma um limite próprio por usuário ou por destino. Quando a API passar a rodar em mais de uma instância, mova os contadores para o Redis. Cada etapa do rate limiting em Node.js leva menos de uma hora e fecha uma porta que bots e scripts automatizados testam todos os dias.