Un singolo client può inviare migliaia di richieste al secondo a un’API Node.js non protetta. Senza un controllo del traffico, quel client esaurisce CPU, memoria e connessioni al database, degrada il servizio per tutti gli altri utenti e apre la porta ad attacchi brute force sui form di login. Il rate limiting è la difesa più semplice ed efficace contro questo scenario, e nel 2026 è considerato un controllo di base in ogni guida alla sicurezza delle API Node.js.
Questo tutorial ti guida, in 12 step pratici, dalla configurazione di un limiter di base con express-rate-limit fino a un sistema distribuito basato su Redis, pronto per la produzione. Tempo di completamento stimato: circa 45 minuti. Alla fine avrai un progetto Express funzionante con limiti differenziati per endpoint, protezione contro gli attacchi di forza bruta, header conformi agli standard IETF e una gestione corretta dei proxy. Tutto il codice è testato su Node.js 22 LTS.
Cos’è il rate limiting e perché è critico nel 2026
Il rate limiting è la tecnica che limita il numero di richieste che un client (identificato per indirizzo IP, chiave API o ID utente) può inviare a un server in una finestra di tempo definita. Quando il client supera la soglia, il server risponde con lo stato HTTP 429 Too Many Requests invece di elaborare la richiesta. È un meccanismo di protezione delle risorse, non di autenticazione: non decide chi può accedere, ma quanto spesso.
L’OWASP classifica l’assenza di questi controlli come API4:2023 Unrestricted Resource Consumption, una delle dieci vulnerabilità più gravi delle API moderne. Senza limiti, un attaccante può esaurire le risorse di calcolo, generare costi cloud incontrollati e rendere indisponibile il servizio. Le linee guida sulla sicurezza Node.js del 2026 trattano il rate limiting come un livello standard, allo stesso titolo di autenticazione, validazione degli input, CORS, header di sicurezza e HTTPS.
La tempistica conta. Il progetto Node.js ha annunciato il rilascio di patch di sicurezza per le linee attive 26.x, 24.x e 22.x a partire dal 17 giugno 2026, con severità massima dichiarata HIGH. In un periodo di aggiornamenti frequenti, mantenere un livello di difesa applicativo come il rate limiting riduce la finestra di esposizione mentre si pianificano i rebuild dei container e i test di regressione. Per chi sviluppa per il mercato italiano ed europeo, queste protezioni si inseriscono anche nel quadro normativo di NIS2 e DORA, che richiedono misure tecniche adeguate contro l’abuso dei servizi.
Il rate limiting protegge da quattro categorie di problemi concreti: gli attacchi brute force e credential stuffing contro gli endpoint di autenticazione, lo scraping massivo dei contenuti, gli abusi che generano costi (invio di email, SMS, chiamate a servizi a pagamento) e i picchi di traffico accidentali causati da client mal configurati. In tutti questi casi, la soglia agisce come una valvola di sicurezza.
I quattro algoritmi di rate limiting a confronto
Prima di scrivere codice, devi capire quale algoritmo usare. La scelta determina la precisione del conteggio, l’uso della memoria e il comportamento durante i picchi di traffico. Esistono quattro famiglie principali, e le librerie Node.js le implementano tutte.
Fixed window, sliding window, token bucket e leaky bucket
Il fixed window conta le richieste in intervalli fissi (per esempio 100 richieste ogni 60 secondi). È semplice e veloce, ma soffre del problema del “bordo della finestra”: un client può inviare 100 richieste alla fine di una finestra e altre 100 all’inizio della successiva, raddoppiando di fatto il limite in pochi istanti. Lo sliding window risolve questo difetto calcolando una finestra mobile che scorre con il tempo, distribuendo il conteggio in modo più uniforme al costo di un calcolo leggermente più oneroso.
Il token bucket riempie un secchio con un numero fisso di token a un ritmo costante: ogni richiesta consuma un token e, quando il secchio è vuoto, le richieste vengono rifiutate. Permette picchi controllati (burst) fino alla capacità del secchio, ed è l’algoritmo preferito per le API pubbliche. Il leaky bucket elabora le richieste a un ritmo costante in uscita, come un secchio che perde acqua da un foro: appiana i picchi forzando un flusso regolare, ideale quando il sistema a valle ha una capacità di elaborazione fissa.
| Algoritmo | Precisione | Gestione burst | Memoria | Caso d’uso tipico |
|---|---|---|---|---|
| Fixed window | Bassa (problema del bordo) | Permette picchi al bordo | Minima (1 contatore) | Limiti grezzi, prototipi |
| Sliding window | Alta | Uniforme | Media | API generiche in produzione |
| Token bucket | Alta | Burst controllato | Bassa (2 valori) | API pubbliche, quote utente |
| Leaky bucket | Alta | Appiana i picchi | Media (coda) | Code verso servizi a capacità fissa |
Per la maggior parte delle API la combinazione vincente è uno sliding window per il traffico generale e un token bucket per le quote per utente. La libreria express-rate-limit implementa un fixed window efficiente per default, mentre rate-limiter-flexible offre token bucket, sliding window e blocco progressivo. Useremo entrambe in questo tutorial.
Prerequisiti e versioni del software
Prima di iniziare, verifica di avere installato gli strumenti corretti. Le versioni indicate sono quelle testate per questo tutorial; usa sempre l’ultima release stabile di ogni componente quando possibile.
- Node.js 22 LTS o versione successiva (consigliata una linea LTS attiva con patch aggiornate)
- npm 10 o superiore, incluso con Node.js
- Express 4.x o 5.x come framework web
- express-rate-limit versione 7 per il rate limiting in-memory
- rate-limiter-flexible per gli algoritmi avanzati e il blocco progressivo
- rate-limit-redis come store distribuito per express-rate-limit
- ioredis come client Redis
- Redis 7 o superiore, in locale o gestito (necessario solo dallo step 6 in poi)
Verifica le versioni di Node.js e npm dal terminale:
$ node --version
v22.14.0
$ npm --version
10.9.2
Per gli step distribuiti ti serve un’istanza Redis. Il modo più rapido in ambiente di sviluppo è Docker:
$ docker run -d --name redis-ratelimit -p 6379:6379 redis:7-alpine
$ docker exec -it redis-ratelimit redis-cli ping
PONG
Se ricevi PONG, Redis è pronto. Una conoscenza di base di Express e delle Promise di JavaScript è utile ma non indispensabile, perché ogni step è commentato. Se parti da zero con la sicurezza delle API, la nostra guida all’autenticazione JWT in Node.js è un buon punto di partenza complementare.
Step 1 e 2: inizializzare il progetto Express
Crea una cartella per il progetto e inizializza npm. Useremo i moduli ES (import/export), quindi imposta "type": "module" nel package.json.
$ mkdir api-rate-limit && cd api-rate-limit
$ npm init -y
$ npm pkg set type=module
$ npm install express express-rate-limit
Crea il file server.js con un server Express minimale e due endpoint: uno pubblico e uno che simula un’operazione sensibile. Questa è la base su cui aggiungeremo i limiti.
// server.js
import express from 'express';
const app = express();
app.use(express.json());
app.get('/api/pubblico', (req, res) => {
res.json({ messaggio: 'Endpoint pubblico, traffico elevato consentito' });
});
app.post('/api/login', (req, res) => {
// Simulazione: in produzione qui verifichi le credenziali
res.json({ messaggio: 'Tentativo di login ricevuto' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server in ascolto sulla porta ${PORT}`);
});
Avvia il server con node server.js e verifica che risponda:
$ curl http://localhost:3000/api/pubblico
{"messaggio":"Endpoint pubblico, traffico elevato consentito"}
A questo punto il server accetta richieste illimitate. Chiunque può inviare migliaia di POST a /api/login e provare combinazioni di credenziali senza alcun freno. Risolviamo subito questo problema.
Step 3: il primo limiter con express-rate-limit
La libreria express-rate-limit è il modo più diretto per aggiungere un limite globale. Funziona come middleware Express: lo crei una volta e lo applichi a tutte le rotte. Il limiter di default usa un algoritmo fixed window con uno store in-memory.
// server.js (aggiornato)
import express from 'express';
import { rateLimit } from 'express-rate-limit';
const app = express();
app.use(express.json());
// Limite globale: 100 richieste ogni 15 minuti per IP
const limiterGlobale = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minuti
limit: 100, // massimo 100 richieste per finestra
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { errore: 'Troppe richieste, riprova piu tardi.' },
});
app.use(limiterGlobale);
app.get('/api/pubblico', (req, res) => {
res.json({ messaggio: 'Endpoint pubblico' });
});
Il parametro windowMs definisce la durata della finestra in millisecondi, mentre limit (in passato chiamato max) fissa il numero massimo di richieste. Quando un IP supera la soglia, il middleware interrompe la catena e restituisce automaticamente lo stato 429 con il messaggio configurato. Tutte le richieste successive nella stessa finestra ricevono lo stesso errore finché il contatore non si azzera.
Riavvia il server e prova a superare il limite con un ciclo. Dopo la centesima richiesta vedrai la risposta cambiare:
$ for i in $(seq 1 101); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/pubblico; done
200 200 200 ... 200 429
La centunesima richiesta restituisce 429. Hai appena protetto l’intera API con poche righe di codice. Nei prossimi step renderemo questo controllo molto più preciso e adatto alla produzione.
Step 4: header standard e comunicazione del limite
Un’API ben progettata comunica al client quanto traffico residuo ha a disposizione. Lo standard IETF definisce i campi header RateLimit proprio per questo scopo. Impostando standardHeaders: 'draft-8' abiliti gli header conformi all’ultima bozza, mentre legacyHeaders: false disattiva i vecchi header X-RateLimit-* non standard.
Ispeziona gli header di una risposta per vederli in azione:
$ curl -i http://localhost:3000/api/pubblico
HTTP/1.1 200 OK
RateLimit-Policy: 100;w=900
RateLimit-Limit: 100
RateLimit-Remaining: 99
RateLimit-Reset: 900
Content-Type: application/json; charset=utf-8
L’header RateLimit-Remaining indica quante richieste restano nella finestra corrente, mentre RateLimit-Reset dice tra quanti secondi il contatore si azzera. Quando arriva la risposta 429, il middleware aggiunge anche l’header Retry-After, che i client ben educati rispettano prima di ritentare. Comunicare questi valori riduce le richieste inutili: un client SDK può attendere il tempo indicato invece di martellare il server.
Esporre questi header è una buona pratica documentata anche dalla guida HTTP di Mozilla per lo stato 429. Evita però di esporre header che rivelano la tua infrastruttura interna, e ricorda che gli header da soli non sostituiscono il rifiuto effettivo della richiesta: sono solo un segnale informativo per i client legittimi.
Step 5: limiti differenziati per endpoint
Un limite globale è un punto di partenza, ma non tutti gli endpoint sono uguali. Una rotta di login deve essere molto più restrittiva di una rotta di lettura pubblica, perché è il bersaglio degli attacchi brute force. La soluzione è creare limiter specifici e applicarli alle singole rotte.
// limiter di autenticazione: solo 5 tentativi ogni 15 minuti
const limiterAuth = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
skipSuccessfulRequests: true, // conta solo i login falliti
message: { errore: 'Troppi tentativi di accesso. Riprova tra 15 minuti.' },
});
// limiter per la creazione di risorse: 20 ogni ora
const limiterScrittura = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 20,
standardHeaders: 'draft-8',
legacyHeaders: false,
});
app.post('/api/login', limiterAuth, (req, res) => {
res.json({ messaggio: 'Tentativo di login ricevuto' });
});
app.post('/api/risorse', limiterScrittura, (req, res) => {
res.status(201).json({ messaggio: 'Risorsa creata' });
});
L’opzione skipSuccessfulRequests: true è particolarmente utile sul login: conta solo le richieste che falliscono (risposta 4xx o 5xx), così un utente legittimo che accede correttamente non consuma il proprio budget. Per far funzionare questo conteggio devi restituire uno stato di errore appropriato quando le credenziali sono sbagliate, ad esempio res.status(401).
| Endpoint | Finestra | Limite | Strategia | Rischio mitigato |
|---|---|---|---|---|
| GET pubblico | 15 min | 100 | Globale | Scraping, picchi accidentali |
| POST /login | 15 min | 5 | Solo fallimenti | Brute force, credential stuffing |
| POST /reset-password | 60 min | 3 | Per IP + per account | Abuso di email, enumerazione |
| POST /risorse | 60 min | 20 | Per utente autenticato | Spam di contenuti |
| POST /invia-email | 24 ore | 50 | Per utente | Costi di invio incontrollati |
Questa stratificazione riflette il principio della difesa in profondità: ogni endpoint riceve un limite proporzionato al suo rischio. Gli endpoint che generano costi o inviano notifiche meritano i limiti più severi. La stessa logica si applica alle rotte di reset password, dove un limite combinato per IP e per account previene sia gli attacchi distribuiti sia l’enumerazione degli utenti.
Step 6: rate limiting distribuito con Redis
Lo store in-memory ha un limite fondamentale: il conteggio vive nel processo Node.js. Se esegui più istanze dietro un load balancer (lo scenario normale in produzione), ogni istanza ha il proprio contatore separato. Un client che colpisce quattro istanze può fare quattro volte le richieste prima di essere bloccato. La soluzione è uno store condiviso, e Redis è la scelta standard.
$ npm install rate-limit-redis ioredis
// redis-store.js
import { RedisStore } from 'rate-limit-redis';
import { Redis } from 'ioredis';
import { rateLimit } from 'express-rate-limit';
export const redisClient = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
// riprova in caso di disconnessione temporanea
maxRetriesPerRequest: 3,
});
export function creaLimiterRedis(opzioni) {
return rateLimit({
standardHeaders: 'draft-8',
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: opzioni.prefix || 'rl:',
}),
...opzioni,
});
}
Ora ogni istanza Node.js scrive il contatore nello stesso Redis. Il client riceve un limite coerente indipendentemente da quale istanza serve la richiesta. Il parametro prefix ti permette di separare i contatori di limiter diversi nella stessa istanza Redis, evitando collisioni tra il limiter globale e quello di autenticazione.
Usa la factory nel server:
import { creaLimiterRedis } from './redis-store.js';
const limiterGlobale = creaLimiterRedis({
windowMs: 15 * 60 * 1000,
limit: 100,
prefix: 'rl:global:',
});
app.use(limiterGlobale);
Verifica i contatori direttamente in Redis durante i test. Le chiavi compaiono con il prefisso scelto:
$ docker exec -it redis-ratelimit redis-cli KEYS 'rl:global:*'
1) "rl:global:::1"
$ docker exec -it redis-ratelimit redis-cli GET 'rl:global:::1'
"42"
Il valore 42 indica le richieste già conteggiate per quell’IP nella finestra corrente. Quando il valore raggiunge il limite, scattano le risposte 429. Redis gestisce automaticamente la scadenza delle chiavi alla fine della finestra tramite TTL.
Step 7: algoritmo token bucket con rate-limiter-flexible
Per quote per utente con burst controllato, rate-limiter-flexible offre un controllo più fine. Supporta token bucket, sliding window, blocco progressivo e diversi backend (Redis, memoria, processo cluster). È la libreria giusta quando hai bisogno di logica personalizzata oltre al semplice middleware.
$ npm install rate-limiter-flexible
// limiter-flexible.js
import { RateLimiterRedis } from 'rate-limiter-flexible';
import { redisClient } from './redis-store.js';
// 10 token, ricarica di 10 token ogni 60 secondi
export const limiterUtente = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rlflex:user',
points: 10, // capacita del secchio (burst)
duration: 60, // finestra di ricarica in secondi
blockDuration: 0, // nessun blocco aggiuntivo oltre la finestra
});
export async function middlewareUtente(req, res, next) {
// usa l'ID utente se autenticato, altrimenti l'IP
const chiave = req.user?.id || req.ip;
try {
const esito = await limiterUtente.consume(chiave, 1);
res.set('RateLimit-Remaining', esito.remainingPoints);
next();
} catch (rejRes) {
const secondi = Math.round(rejRes.msBeforeNext / 1000) || 1;
res.set('Retry-After', String(secondi));
res.status(429).json({ errore: 'Quota esaurita', riprovaTra: secondi });
}
}
Il metodo consume(chiave, punti) sottrae i punti dal budget e restituisce una Promise. Se ci sono punti sufficienti, la Promise si risolve con i punti rimanenti; altrimenti viene rifiutata con un oggetto che contiene msBeforeNext, il tempo in millisecondi prima della prossima ricarica. Puoi assegnare un costo diverso a operazioni diverse: una query pesante può consumare 5 punti invece di 1, riflettendo il suo impatto reale sulle risorse.
Questo approccio basato sul costo è potente: invece di contare le richieste, conti il consumo effettivo di risorse. Un’API che combina rate limiting con autenticazione robusta, come spieghiamo nella guida ai codici TOTP 2FA in Node.js, ottiene una difesa molto più solida contro gli abusi automatizzati.
Step 8: gestire i proxy e l’header X-Forwarded-For
Questo è l’errore più comune e più pericoloso nelle configurazioni reali. Quando la tua app gira dietro un reverse proxy (Nginx, un load balancer cloud, Cloudflare), l’indirizzo IP che Express vede in req.ip è quello del proxy, non quello del client reale. Se non configuri correttamente la fiducia nel proxy, succede una di due cose: tutti i client condividono lo stesso contatore (perché hanno tutti l’IP del proxy) oppure un attaccante può falsificare l’header per evadere i limiti.
Express usa l’impostazione trust proxy per decidere di quali proxy fidarsi. Non impostarla mai su true in modo indiscriminato in produzione, perché consente a chiunque di falsificare l’header X-Forwarded-For. Specifica invece il numero esatto di proxy davanti alla tua app o i loro indirizzi.
// CORRETTO: un solo proxy fidato davanti all'app
app.set('trust proxy', 1);
// CORRETTO: fidati solo di indirizzi specifici
app.set('trust proxy', ['127.0.0.1', '10.0.0.0/8']);
// PERICOLOSO: non fare questo in produzione
// app.set('trust proxy', true);
Con trust proxy impostato correttamente su 1, Express prende l’ultimo IP nella catena X-Forwarded-For come IP del client, ignorando i valori che un client potrebbe aver iniettato a monte. La documentazione ufficiale di Express dedica una pagina a questo comportamento, ed è una lettura obbligatoria prima di andare in produzione. Le versioni recenti di express-rate-limit emettono un avviso se rilevano una configurazione trust proxy potenzialmente insicura.
Verifica quale IP viene effettivamente usato aggiungendo temporaneamente una rotta di debug che restituisce req.ip, e controlla che corrisponda all’IP reale del client passando attraverso il tuo proxy. Questo semplice test ti evita ore di debug quando i limiti sembrano comportarsi in modo strano.
Step 9: blocco progressivo contro il brute force
Per gli endpoint di autenticazione, un limite fisso non basta. La tecnica migliore è il blocco progressivo: dopo un certo numero di fallimenti consecutivi, il client viene bloccato per un periodo crescente. rate-limiter-flexible supporta questo schema combinando due limiter e l’opzione blockDuration.
// brute-force.js
import { RateLimiterRedis } from 'rate-limiter-flexible';
import { redisClient } from './redis-store.js';
// blocca 5 tentativi falliti, poi blocco di 15 minuti
const limiterFalliti = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_fail',
points: 5,
duration: 60 * 15,
blockDuration: 60 * 15,
});
export async function registraFallimento(ip) {
try {
await limiterFalliti.consume(ip, 1);
} catch (rejRes) {
// l'utente e ora bloccato per blockDuration
}
}
export async function resetTentativi(ip) {
await limiterFalliti.delete(ip); // login riuscito: azzera
}
La logica è chiara: ogni login fallito chiama registraFallimento, che consuma un punto. Al sesto fallimento il limiter rifiuta e applica blockDuration, bloccando l’IP per 15 minuti anche se smette di provare. Un login riuscito chiama resetTentativi, che cancella il contatore e restituisce all’utente legittimo il pieno budget. Questo schema rende inefficace il credential stuffing senza penalizzare chi sbaglia la password una volta.
Per un’ulteriore robustezza, puoi mantenere due chiavi separate: una per IP e una per coppia IP+username. In questo modo un attaccante che prova molti username dallo stesso IP viene fermato, ma un singolo utente non blocca altri utenti dietro lo stesso IP aziendale condiviso. Questa difesa si integra bene con le altre misure descritte nella nostra guida alla protezione CSRF in Node.js.
Step 10: risposte 429 personalizzate e Retry-After
Una risposta 429 generica frustra gli sviluppatori che integrano la tua API. Una risposta ben fatta indica chiaramente cosa è successo, quando riprovare e dove leggere la documentazione. Personalizza l’handler con l’opzione handler di express-rate-limit.
const limiterConHandler = creaLimiterRedis({
windowMs: 15 * 60 * 1000,
limit: 100,
prefix: 'rl:api:',
handler: (req, res, next, options) => {
const resetSecondi = Math.ceil(options.windowMs / 1000);
res.set('Retry-After', String(resetSecondi));
res.status(options.statusCode).json({
errore: 'rate_limit_superato',
messaggio: 'Hai superato il limite di richieste consentito.',
limite: options.limit,
riprovaTra: resetSecondi,
documentazione: 'https://api.esempio.it/docs/rate-limit',
});
},
});
L’header Retry-After è la parte più importante: comunica al client esattamente quanti secondi attendere. I client SDK ben scritti leggono questo valore e mettono in pausa le richieste, riducendo il carico sul tuo server. La risposta JSON strutturata, con un codice di errore stabile come rate_limit_superato, permette ai client di gestire l’errore in modo programmatico invece di analizzare il testo del messaggio.
Ecco come appare la risposta completa quando il limite è superato:
HTTP/1.1 429 Too Many Requests
Retry-After: 900
RateLimit-Remaining: 0
Content-Type: application/json; charset=utf-8
{
"errore": "rate_limit_superato",
"messaggio": "Hai superato il limite di richieste consentito.",
"limite": 100,
"riprovaTra": 900,
"documentazione": "https://api.esempio.it/docs/rate-limit"
}
Step 11: logging e monitoraggio degli abusi
Bloccare le richieste è solo metà del lavoro: devi anche sapere quando e perché scattano i limiti. Un picco improvviso di risposte 429 può indicare un attacco in corso, un bug in un client o un limite tarato male. Registra ogni evento di superamento con abbastanza contesto da poter indagare.
const limiterMonitorato = creaLimiterRedis({
windowMs: 15 * 60 * 1000,
limit: 100,
prefix: 'rl:mon:',
handler: (req, res, next, options) => {
console.warn(JSON.stringify({
evento: 'rate_limit_superato',
ip: req.ip,
percorso: req.originalUrl,
metodo: req.method,
userAgent: req.get('user-agent'),
timestamp: new Date().toISOString(),
}));
res.status(429).json({ errore: 'Troppe richieste' });
},
});
In produzione invia questi log a un sistema centralizzato (ELK, Grafana Loki, o un servizio gestito) e crea un avviso quando il tasso di 429 supera una soglia. Una metrica utile è il rapporto tra IP unici bloccati e IP unici totali: se un singolo IP genera la maggior parte dei blocchi, probabilmente è un attaccante o un bot; se i blocchi sono distribuiti su molti IP, forse il tuo limite è troppo basso per il traffico legittimo.
Evita di registrare dati sensibili come token o password che potrebbero finire nel corpo della richiesta. Registra l’IP, il percorso e lo user agent, ma mai le credenziali. Per ambienti soggetti al GDPR, valuta la pseudonimizzazione degli indirizzi IP nei log a lungo termine, mantenendo l’IP in chiaro solo per la finestra di indagine necessaria.
Step 12: testare il rate limiter sotto carico
Non fidarti di un limiter che non hai testato. Lo strumento autocannon genera traffico realistico e ti mostra esattamente quante richieste passano e quante vengono respinte. Installalo globalmente o eseguilo con npx.
$ npx autocannon -c 10 -d 5 -m GET http://localhost:3000/api/pubblico
Running 5s test @ http://localhost:3000/api/pubblico
10 connections
stat 2xx non-2xx
totale 100 1.243
Req/Sec 268.6
In questo esempio, su circa 1.343 richieste totali solo 100 hanno ricevuto 200 (il limite della finestra) e le restanti 1.243 hanno ricevuto 429. È esattamente il comportamento atteso: il limite di 100 viene rispettato e tutto il resto viene respinto senza che il server vada in sovraccarico. Scrivi anche un test automatico che invii N+1 richieste e verifichi che l’ultima riceva 429, così la protezione resta verificata a ogni deploy.
// test/ratelimit.test.js (con node:test)
import { test } from 'node:test';
import assert from 'node:assert';
test('blocca dopo il limite', async () => {
let ultimoStato;
for (let i = 0; i < 101; i++) {
const r = await fetch('http://localhost:3000/api/pubblico');
ultimoStato = r.status;
}
assert.strictEqual(ultimoStato, 429);
});
5 errori comuni da evitare
Anche con le librerie giuste, alcune trappole ricorrono in quasi tutti i progetti. Eccole, con la soluzione.
- Configurare trust proxy in modo errato. È l’errore numero uno. Senza la configurazione corretta dietro un proxy, o tutti gli utenti condividono un contatore o gli attaccanti falsificano l’IP. Imposta
trust proxysul numero esatto di proxy fidati, mai sutrueaperto. - Usare lo store in-memory in produzione multi-istanza. Il contatore in memoria non è condiviso tra processi. Con più istanze il limite reale si moltiplica per il numero di istanze. Passa a Redis dallo step 6.
- Limitare per IP gli utenti dietro NAT. Reti aziendali e operatori mobili condividono pochi IP tra molti utenti. Un limite per IP troppo basso blocca utenti legittimi. Per gli utenti autenticati, limita per ID utente invece che per IP.
- Contare i login riusciti. Senza
skipSuccessfulRequests, un utente che accede normalmente esaurisce il budget di tentativi. Conta solo i fallimenti sugli endpoint di autenticazione. - Dimenticare la gestione degli errori di Redis. Se Redis non è raggiungibile e non gestisci l’errore, il limiter può bloccare tutto il traffico o lasciarlo passare senza controllo. Definisci una strategia di fallback esplicita.
Risoluzione dei problemi: 8 casi frequenti
Quando il rate limiter non si comporta come previsto, questi sono i sintomi più comuni e le rispettive cause.
| Sintomo | Causa probabile | Soluzione |
|---|---|---|
| Tutti gli utenti bloccati insieme | trust proxy non configurato, tutti vedono l’IP del proxy | Imposta app.set(‘trust proxy’, 1) |
| Il limite non scatta mai | Middleware registrato dopo le rotte | Registra il limiter prima delle rotte |
| Limite doppio o triplo del previsto | Store in-memory con più istanze | Usa lo store Redis condiviso |
| Errore “Cannot read property of undefined” | req.user non definito prima dell’auth | Usa fallback req.user?.id || req.ip |
| 429 anche con poco traffico | windowMs o limit tarati male | Verifica i valori, ricorda i ms |
| Header RateLimit assenti | standardHeaders non impostato | Imposta standardHeaders: ‘draft-8’ |
| Contatori che non scadono in Redis | TTL non applicato dallo store | Aggiorna rate-limit-redis all’ultima versione |
| Traffico bloccato quando Redis cade | Nessuna strategia di fallback | Gestisci l’errore del client Redis |
Per il caso del fallback Redis, la decisione progettuale è importante: vuoi “fail open” (lasciar passare il traffico se Redis non risponde, privilegiando la disponibilità) o “fail closed” (bloccare, privilegiando la sicurezza)? Per la maggior parte delle API pubbliche il fail open con un limiter in-memory di emergenza è il compromesso giusto, perché evita un’interruzione totale del servizio per un guasto dello store. Documenta sempre la scelta nel codice.
redisClient.on('error', (err) => {
console.error('Redis non raggiungibile:', err.message);
// strategia fail open: il middleware usa un fallback in-memory
});
Consigli avanzati per la produzione
Una volta padroneggiate le basi, queste tecniche portano il tuo rate limiting al livello successivo. Sono le pratiche che distinguono un’API resistente da una semplicemente funzionante.
- Limiti per piano tariffario. Se offri tier diversi (gratuito, pro, enterprise), assegna limiti dinamici in base al piano dell’utente leggendo
req.user.plane calcolandolimitcon una funzione invece di un valore fisso. - Whitelisting selettivo. Usa l’opzione
skipper escludere indirizzi interni di monitoraggio o partner fidati, ma mantieni la lista corta e versionata nel codice o in una configurazione sicura. - Rate limiting a più livelli. Combina un limite breve e aggressivo (per secondo) con uno lungo e generoso (per giorno). Il primo ferma i burst, il secondo impone una quota complessiva.
- Costo variabile per operazione. Con
rate-limiter-flexible, fai consumare più punti alle operazioni costose. Una ricerca complessa può valere 10 punti, una lettura semplice 1. - Sincronizza il rate limiting con il tuo gateway. Se usi un API gateway o un WAF a monte, coordina i limiti per evitare doppi conteggi e definisci chiaramente quale livello è la fonte di verità.
Un’ultima raccomandazione operativa: rivedi periodicamente i tuoi limiti analizzando i dati reali di traffico. Limiti tarati una volta e mai aggiornati diventano o troppo stretti (frustrano gli utenti che crescono) o troppo larghi (non proteggono più). Tratta le soglie come configurazione viva, non come costanti scolpite nella pietra.
Il progetto completo funzionante
Ecco il file server.js completo che mette insieme tutti gli step: limite globale su Redis, limiter di autenticazione con blocco progressivo, header standard, gestione proxy e risposte 429 strutturate. È pronto per essere adattato alla tua API.
// server.js (progetto completo)
import express from 'express';
import { creaLimiterRedis, redisClient } from './redis-store.js';
import { registraFallimento, resetTentativi } from './brute-force.js';
const app = express();
app.use(express.json());
// 1. Fidati di un solo proxy davanti all'app
app.set('trust proxy', 1);
// 2. Limite globale condiviso via Redis
app.use(creaLimiterRedis({
windowMs: 15 * 60 * 1000,
limit: 100,
prefix: 'rl:global:',
}));
// 3. Limiter severo per l'autenticazione
const limiterAuth = creaLimiterRedis({
windowMs: 15 * 60 * 1000,
limit: 5,
prefix: 'rl:auth:',
skipSuccessfulRequests: true,
});
app.get('/api/pubblico', (req, res) => {
res.json({ messaggio: 'Endpoint pubblico' });
});
app.post('/api/login', limiterAuth, async (req, res) => {
const { username, password } = req.body;
const valido = username === 'demo' && password === 'segreto';
if (!valido) {
await registraFallimento(req.ip);
return res.status(401).json({ errore: 'Credenziali non valide' });
}
await resetTentativi(req.ip);
res.json({ messaggio: 'Accesso effettuato', token: 'jwt-di-esempio' });
});
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
console.log(`Server protetto in ascolto sulla porta ${PORT}`);
});
// Chiusura pulita: rilascia la connessione Redis
process.on('SIGTERM', async () => {
await redisClient.quit();
server.close();
});
La struttura finale del progetto comprende quattro file: server.js per le rotte, redis-store.js per la factory dei limiter, limiter-flexible.js per le quote per utente e brute-force.js per il blocco progressivo. Questa separazione mantiene il codice leggibile e ti permette di riutilizzare i limiter in più servizi. Per approfondire la cifratura dei dati che la tua API gestisce, la guida alla crittografia end-to-end in Node.js completa il quadro della sicurezza applicativa.
Conclusioni: il rate limiting come livello di base
Hai costruito un sistema di rate limiting completo, dal middleware in-memory di tre righe fino a un’architettura distribuita su Redis con blocco progressivo, header standard e monitoraggio. Il punto chiave è che il rate limiting non è una funzionalità opzionale ma un livello di difesa di base, allo stesso titolo di HTTPS e validazione degli input.
Parti sempre da limiti conservativi e allentali in base ai dati reali, mai il contrario. Tieni Redis come fonte di verità in produzione, configura trust proxy con cura e tratta gli endpoint di autenticazione come la priorità assoluta. Con queste fondamenta, la tua API Node.js resiste ad attacchi brute force, scraping e picchi accidentali senza penalizzare gli utenti legittimi. Per mantenere la protezione efficace, rivedi le soglie a ogni rilascio importante e aggiorna le librerie quando escono nuove versioni di sicurezza di Node.js.
Approfondimenti correlati
- Autenticazione JWT in Node.js: 12 Step
- Protezione CSRF in Node.js: 12 Step
- TOTP 2FA in Node.js: Autenticatore in 12 Step
- Hashing Password con bcrypt in Node.js: 12 Step
- Crittografia End-to-End in Node.js: 12 Step
- Let’s Encrypt e Certbot: HTTPS Gratis in 10 Step
Per approfondire gli standard tecnici citati, consulta le fonti ufficiali: l’avviso di sicurezza di Node.js per giugno 2026, la voce OWASP API4:2023 Unrestricted Resource Consumption, la documentazione di express-rate-limit e di rate-limiter-flexible, la guida di Express su come operare dietro i proxy e la pagina Mozilla sullo stato 429 Too Many Requests.
Domande frequenti sul rate limiting in Node.js
Qual è la differenza tra express-rate-limit e rate-limiter-flexible?
express-rate-limit è un middleware semplice e dichiarativo, ideale per applicare limiti per IP alle rotte Express con poche righe di configurazione. rate-limiter-flexible è una libreria più potente che offre token bucket, sliding window, blocco progressivo e costi variabili per operazione, oltre al supporto per più backend. Usa la prima per i casi standard e la seconda quando ti serve logica personalizzata, quote per utente o protezione brute force avanzata.
Devo usare per forza Redis per il rate limiting?
No, se esegui una singola istanza Node.js lo store in-memory è sufficiente e più semplice. Redis diventa necessario quando esegui più istanze dietro un load balancer, perché il conteggio deve essere condiviso tra i processi. Poiché quasi tutte le applicazioni in produzione scalano orizzontalmente, Redis è la scelta standard per gli ambienti reali.
Come imposto i limiti per gli utenti dietro lo stesso indirizzo IP?
Limita per ID utente invece che per IP quando l’utente è autenticato. Usa una chiave come req.user?.id || req.ip, così gli utenti autenticati hanno un budget individuale e solo il traffico anonimo viene limitato per IP. Questo evita di bloccare interi uffici o reti mobili che condividono pochi indirizzi pubblici tramite NAT.
Cosa succede al rate limiting se Redis va offline?
Dipende dalla strategia che configuri. Con “fail open” il traffico passa anche senza Redis, privilegiando la disponibilità; con “fail closed” le richieste vengono bloccate, privilegiando la sicurezza. Per le API pubbliche è comune il fail open con un limiter in-memory di emergenza, così un guasto di Redis non causa un’interruzione totale del servizio. Gestisci sempre l’evento error del client Redis.
Il rate limiting protegge da attacchi DDoS?
Solo in parte. Il rate limiting a livello applicativo ferma gli abusi da singoli client e gli attacchi brute force, ma non regge un DDoS volumetrico distribuito su decine di migliaia di IP, perché le richieste arrivano comunque al tuo server. Per i DDoS servono difese a monte come un CDN, un WAF o la protezione del provider cloud. Il rate limiting è un livello complementare, non sostitutivo.
Quale stato HTTP devo restituire quando il limite è superato?
Lo stato corretto è 429 Too Many Requests, definito dallo standard HTTP proprio per questo scopo. Accompagnalo sempre con l’header Retry-After, che indica al client quanti secondi attendere prima di riprovare. Evita di usare 403 o 503, perché hanno significati diversi e confondono i client che integrano la tua API.
Quanto devono essere severi i limiti sugli endpoint di login?
Molto più severi del traffico normale. Un valore di riferimento ragionevole è 5 tentativi falliti ogni 15 minuti per IP, con blocco progressivo dopo il superamento. Conta solo i fallimenti, non gli accessi riusciti, e azzera il contatore quando l’utente accede correttamente. Combina questa misura con l’autenticazione a due fattori per una protezione robusta contro il credential stuffing.




