ITSkillsCenter
Blog

Rate-limiting distribué avec @nestjs/throttler et Redis

11 min de lecture

Le rate-limiting est la barrière qui empêche un attaquant ou un client mal codé de saturer une API. NestJS expose @nestjs/throttler, un module simple à mettre en place mais qui par défaut stocke ses compteurs en mémoire — ce qui le rend inutile dès qu’on déploie deux instances derrière un load balancer. Ce tutoriel monte un rate-limiting distribué qui partage l’état via Redis, différencie les compteurs par IP et par utilisateur authentifié, expose les headers RateLimit standardisés (draft IETF), et gère les réseaux derrière proxy.

📍 Article principal : NestJS 11 pour startup : architecture production 2026. Cette protection se combine avec les guards d’authentification pour bloquer les attaques par force brute sur les endpoints sensibles.

Prérequis

  • API NestJS 11 avec authentification JWT — voir le tutoriel JWT + Casbin
  • Instance Redis 7 ou 8 accessible — local via Docker ou managé
  • Compréhension des codes HTTP 429 et des headers Retry-After
  • Temps estimé : 60 minutes

Étape 1 — Lancer Redis en local

Redis est l’outil le plus discret de cette pile : un service mémoire avec persistance optionnelle qui répond en moins d’une milliseconde. Pour le développement, l’image Docker officielle suffit. Une seule instance porte à la fois le rate-limiter, le cache, et plus tard les queues BullMQ — Redis isole les usages via les bases numériques (de 0 à 15) ou via des préfixes de clé.

# docker-compose.dev.yml (extrait)
services:
  redis:
    image: redis:8-alpine
    ports: ["6379:6379"]
    command: ["redis-server", "--appendonly", "yes"]

L’option --appendonly yes active la persistance AOF, ce qui empêche la perte des données entre deux redémarrages. Pour le rate-limiter pur, la persistance n’est pas critique — perdre les compteurs ne libère qu’un instant. Mais quand le même Redis sert aussi de file BullMQ, l’AOF devient indispensable. Vérifier la connexion avec redis-cli -h localhost ping qui doit répondre PONG.

Étape 2 — Installer @nestjs/throttler et le storage Redis

L’écosystème offre deux paquets complémentaires : @nestjs/throttler qui contient le module et le guard, et @nest-lab/throttler-storage-redis qui implémente le storage backend partagé. Cette séparation permet de basculer entre stockage mémoire (par défaut) et stockage Redis sans toucher à la logique applicative.

cd apps/api
pnpm add @nestjs/throttler @nest-lab/throttler-storage-redis ioredis

Le client ioredis est privilégié à node-redis dans l’écosystème BullMQ et Throttler parce qu’il supporte mieux les clusters Redis et reconnecte automatiquement après une coupure réseau. Une instance partagée d’ioredis peut servir le rate-limiter et plus tard BullMQ — économisant un pool de connexions. La configuration accepte un host, un port, et un mot de passe ; en production, ne jamais coder ces valeurs en dur.

Étape 3 — Configurer ThrottlerModule avec storage Redis

Le module se configure dans app.module.ts via ThrottlerModule.forRootAsync pour pouvoir injecter le client Redis. La configuration accepte plusieurs throttlers nommés, ce qui permet de définir des limites différentes selon les routes : un throttler permissif pour la lecture (1000 req/min), un strict pour le login (5 req/min), un très strict pour l’inscription (3 req/heure).

// app.module.ts
ThrottlerModule.forRootAsync({
  useFactory: () => ({
    throttlers: [
      { name: 'short', ttl: 1000, limit: 10 },
      { name: 'medium', ttl: 60_000, limit: 100 },
      { name: 'auth', ttl: 60_000, limit: 5 },
    ],
    storage: new ThrottlerStorageRedisService(
      new Redis({ host: 'localhost', port: 6379 })
    ),
  }),
})

Trois throttlers couvrent la majorité des cas. short bloque les rafales courtes (10 requêtes par seconde), medium impose un débit moyen acceptable, auth protège les endpoints d’authentification. Chaque endpoint peut décider quels throttlers s’appliquent via le décorateur @Throttle({ short: ..., medium: ... }). Le storage Redis garantit que toutes les instances partagent les mêmes compteurs.

Étape 4 — Activer le guard global et les exemptions

Le ThrottlerGuard s’applique globalement via la même mécanique que JwtAuthGuard, en s’enregistrant dans APP_GUARD. Cette protection par défaut force les développeurs à réfléchir explicitement aux exemptions plutôt qu’à oublier de protéger un endpoint. Le décorateur @SkipThrottle() retire la limitation pour les endpoints internes (healthcheck, métriques Prometheus).

// app.module.ts
providers: [
  { provide: APP_GUARD, useClass: ThrottlerGuard },
],

// health/health.controller.ts
@Controller('health')
@SkipThrottle()
export class HealthController { /* ... */ }

Les endpoints /health/live et /health/ready doivent rester rapides et imprévisibles en débit, parce que les sondes Kubernetes ou Docker peuvent les interroger toutes les 5 secondes depuis plusieurs IP. Les rate-limiter expose un risque de faux positifs sur ces routes. L’exemption évite des incidents service unavailable dus à des sondes trop zélées.

Étape 5 — Différencier les compteurs par utilisateur authentifié

Le comportement par défaut compte les requêtes par IP, ce qui est correct pour les endpoints publics mais injuste sur les endpoints authentifiés : un utilisateur derrière un NAT d’entreprise partagerait son quota avec ses collègues. La parade consiste à surcharger la méthode getTracker du guard pour utiliser l’ID utilisateur quand il est disponible, et l’IP sinon.

// throttler/auth-throttler.guard.ts
@Injectable()
export class AuthThrottlerGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.user?.id ?? req.ip;
  }
}

Le guard custom remplace ThrottlerGuard dans APP_GUARD. Désormais, deux utilisateurs distincts derrière la même IP ne se gênent plus. À l’inverse, un utilisateur qui ouvre dix onglets et envoie dix requêtes simultanées avec le même token reste bien limité, parce que tous portent le même req.user.id. Cette différenciation simple résout 90 % des frustrations utilisateurs sur les API authentifiées.

Étape 6 — Headers RateLimit standardisés et UX côté client

Une API courtoise indique au client combien de requêtes il lui reste avant le blocage et dans combien de temps il pourra réessayer. Le draft IETF draft-ietf-httpapi-ratelimit-headers (en cours de finalisation au sein du groupe HTTPAPI) standardise quatre headers : RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, et Retry-After en cas de 429. @nestjs/throttler expose ces headers automatiquement depuis la version 6.

// requête bloquée — réponse HTTP 429
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 42
Retry-After: 42
Content-Type: application/json
{"statusCode":429,"message":"ThrottlerException: Too Many Requests"}

Côté client, ces headers permettent d’afficher un compte à rebours plutôt qu’une erreur générique. Une SPA bien faite intercepte les 429 dans un middleware Axios ou un Apollo Link, lit Retry-After, et affiche un toast réessayez dans 42 secondes. Cette UX fait toute la différence entre une API perçue comme cassée et une API perçue comme protégée.

Étape 7 — Gérer les proxies et les vraies IP

En production derrière un reverse proxy (Traefik, Nginx, Cloudflare), req.ip contient l’IP du proxy, pas celle du client. Sans configuration, tous les clients partagent le même tracker et le rate-limiter devient inutile. La solution est d’activer trust proxy côté Express et de lire X-Forwarded-For en respectant la chaîne de confiance.

// main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1); // 1 niveau de proxy

La valeur numérique indique combien de proxies se trouvent devant l’API. Avec Coolify et Traefik, c’est 1. Avec Cloudflare devant Coolify, c’est 2. Mettre une valeur true aveugle accepterait n’importe quel header X-Forwarded-For envoyé par le client, ce qui permettrait à un attaquant de se faire passer pour n’importe quelle IP et de contourner le rate-limit. La configuration doit refléter la topologie réelle du déploiement.

Étape 8 — Tester en charge avec autocannon

Une protection non testée n’existe pas. Avant de partir en production, vérifier que le rate-limiter répond comme prévu sous charge réelle. autocannon est l’outil de référence pour les bench HTTP en Node.js : il génère des milliers de requêtes par seconde et rapporte la distribution des codes de réponse. Un test bien conçu cible précisément l’endpoint /auth/login avec dix payloads invalides et vérifie qu’au-delà de 5 requêtes, le serveur renvoie 429.

npx autocannon -c 50 -d 10 -m POST \
  -H "Content-Type=application/json" \
  -b '{"email":"x@x","password":"y"}' \
  http://localhost:3000/auth/login

Le rapport doit montrer que la grande majorité des requêtes retourne 429 après les premières secondes. Si le serveur répond 401 ou 500 sur toute la durée, le rate-limiter ne fonctionne pas — vérifier que le storage Redis est bien connecté et que le throttler auth est appliqué à l’endpoint. Cette vérification prend cinq minutes mais évite bien des incidents.

Étape 9 — Stratégies avancées : sliding window et token bucket

Le throttler par défaut utilise un fixed window simple : le compteur se remet à zéro à chaque période. Cette stratégie souffre du burst de bord, où un client peut envoyer 5 requêtes à la 59e seconde et 5 autres à la 1ère seconde de la période suivante, soit 10 requêtes en deux secondes alors que la limite est censée être 5 par minute. Pour les endpoints critiques, basculer sur un sliding window ou un token bucket élimine ce contournement.

// throttler/sliding-window.guard.ts (extrait de logique)
const key = `rl:${tracker}:${route}`;
const now = Date.now();
await redis.zremrangebyscore(key, 0, now - windowMs);
const count = await redis.zcard(key);
if (count >= limit) throw new ThrottlerException();
await redis.zadd(key, now, `${now}-${randomId()}`);
await redis.expire(key, Math.ceil(windowMs / 1000));

Le sliding window enregistre chaque requête dans un sorted set Redis avec son timestamp comme score, supprime les entrées plus vieilles que la fenêtre, puis vérifie le cardinal. La précision est de l’ordre de la milliseconde et le burst de bord disparaît. Le coût en mémoire reste minimal — quelques dizaines de bytes par requête active.

Erreurs fréquentes

Erreur Cause Solution
Limit non partagé entre instances Storage par défaut en mémoire ThrottlerStorageRedisService
Toutes les IP partagent le quota trust proxy non configuré app.set('trust proxy', N)
Health checks en 429 Endpoint non exempté @SkipThrottle sur HealthController
Compteur cassé après reboot Redis AOF désactivé sur queue partagée –appendonly yes
WebSocket non protégé Throttler default sur HTTP only WsThrottlerGuard custom pour Socket.IO

Le piège des WebSockets est subtil : ThrottlerGuard intercepte le HTTP standard, mais une connexion WebSocket maintenue ouverte peut envoyer des dizaines de messages sans repasser par le HTTP. Pour les API qui exposent du temps réel, étendre ThrottlerGuard en surchargant handleRequest pour reconnaître les contextes WebSocket et appliquer la même logique reste la bonne pratique. Sinon, des messages malveillants peuvent contourner la limite sans difficulté.

Coexistence avec un cache applicatif

La même instance Redis qui sert le throttler peut héberger un cache applicatif via cache-manager-ioredis-yet ou directement avec un client typé. Pour éviter la pollution des préfixes, attribuer une base différente à chaque usage : base 0 pour le rate-limiter, base 1 pour le cache, base 2 plus tard pour BullMQ. Cette discipline évite qu’un FLUSHDB de débogage sur le cache n’efface tous les compteurs de rate-limit en production.

Pour les déploiements à fort trafic, isoler chaque usage sur sa propre instance Redis reste l’option la plus saine. Une saturation du cache (un job qui écrit des millions de clés) ne doit jamais bloquer la file BullMQ ou le rate-limiter. Le coût d’une seconde instance Redis sur Coolify est marginal et la séparation des préoccupations vaut le surcoût.

Surveillance des compteurs en production

Brancher Redis sur un dashboard Grafana via redis_exporter donne une vision en temps réel des clés actives, du taux de hits, et de la mémoire consommée. Une alerte simple sur un nombre anormal de 429 sur cinq minutes attrape les attaques avant qu’elles ne saturent.

FAQ

Faut-il préférer Redis ou Memcached ?
Redis l’emporte sur tous les axes en 2026 : il supporte les types de données complexes utiles pour le rate-limiting, il sert aussi BullMQ et le cache, et il offre une persistance optionnelle. Memcached est limité au cache pur et n’a pas évolué depuis des années.

Quelle limite pour /auth/login ?
5 tentatives par minute par IP/utilisateur est un bon compromis. Trop strict (1 par minute) frustre les utilisateurs qui se trompent de mot de passe ; trop laxiste (50 par minute) laisse passer des attaques par dictionnaire. Combiner avec un blocage progressif (10 minutes après 5 échecs, 1 heure après 10 échecs) est une amélioration classique.

Comment monitorer les blocages ?
Émettre un log structuré {"event":"throttle","tracker":"...","route":"...","limit":...} à chaque 429 et le pousser vers Loki. Une alerte Grafana se déclenche au-delà de 10 % de 429 sur cinq minutes — souvent signe d’attaque ou de bug client.

Le rate-limiting peut-il remplacer un WAF ?
Non. Le rate-limiter limite la fréquence ; un WAF (Cloudflare, AWS WAF) inspecte le contenu et bloque les patterns d’injection SQL ou XSS. Les deux sont complémentaires.

Tutoriels associés

Références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité