Rate limiting avanzato in Laravel: proteggere le API da abusi senza bloccare utenti legittimi

Rate limiting avanzato in Laravel: proteggere le API da abusi senza bloccare utenti legittimi

A dicembre 2025 un cliente del settore servizi digitali mi ha segnalato che la sua API pubblica Laravel - un servizio di verifica codici fiscali e partite IVA usato da una ventina di integratori - stava subendo un degrado delle prestazioni intermittente. L'analisi dei log ha rivelato il problema: un singolo IP stava inviando 4.000 richieste al minuto all'endpoint di verifica, scaricando sistematicamente l'intero dataset di validazione. L'API aveva il middleware throttle:60,1 di default di Laravel - 60 richieste al minuto per IP - ma lo scraper usava un pool di 20 IP che ruotava ad ogni blocco, superando il throttle con 20 × 60 = 1.200 richieste al minuto distribuite su più IP. Quando ha aggiunto altri IP al pool, le richieste sono salite a 4.000 al minuto e il server ha iniziato a rallentare per tutti gli utenti legittimi.

Il middleware throttle di default di Laravel è un punto di partenza ragionevole per la protezione base, ma per un'API pubblica esposta a Internet non è sufficiente. Un attaccante motivato lo bypassa in secondi con la rotazione degli IP. Serve un sistema di rate limiting multi-livello che consideri non solo l'IP ma anche la chiave API, l'endpoint specifico, il pattern temporale delle richieste e il carico corrente del server. In questo articolo ti mostro il sistema che ho implementato - dalla configurazione base di Laravel al rate limiting adattivo che scala automaticamente le restrizioni in base al carico - con codice reale e le metriche di efficacia misurate in produzione.

Perché il throttle di default di Laravel non basta per le API pubbliche?

Il middleware throttle di Laravel implementa un rate limiter basato su fixed window: conta le richieste in una finestra temporale fissa (ad esempio, 1 minuto) e blocca le richieste che superano il limite. Il problema del fixed window è il boundary burst: un client può inviare 60 richieste nell'ultimo secondo del minuto corrente e altre 60 nel primo secondo del minuto successivo - 120 richieste in 2 secondi - rispettando tecnicamente il limite di 60/minuto. Per un'API che deve proteggere un database sotto carico, questo burst di 120 richieste in 2 secondi può essere devastante.

Il secondo problema è l'identificazione del client. Il throttle di default usa l'IP della richiesta come chiave di rate limiting - il che funziona quando ogni client ha un IP unico, ma fallisce quando un client usa un pool di IP (proxying, VPN, botnet), quando più client legittimi condividono lo stesso IP (aziende con NAT, utenti dello stesso ISP con CGNAT), o quando il client è un servizio cloud con IP dinamici. Un rate limit per IP che blocca un ufficio con 50 dipendenti dietro lo stesso IP dopo 60 richieste totali è un falso positivo che penalizza gli utenti legittimi senza fermare lo scraper con 20 IP.

La soluzione è un rate limiting multi-livello che usa chiavi diverse per livelli diversi di granularità: per IP come difesa base, per API key come difesa per cliente, per endpoint come protezione differenziata, e per carico del server come valvola di sicurezza globale. Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nella protezione di API in produzione - un'area dove la differenza tra protezione efficace e protezione che blocca i clienti legittimi sta nella granularità delle regole e nella qualità del monitoring.

Il sistema multi-livello: dalla configurazione alla produzione

Il rate limiting che implemento per le API Laravel dei clienti opera su quattro livelli simultanei, ciascuno con una funzione specifica:

Livello 1 - Rate limit per IP (difesa base). Il primo livello protegge contro gli attacchi brute force da singoli IP. Uso un sliding window in Redis invece del fixed window di default - il sliding window non ha il problema del boundary burst perché la finestra si sposta con ogni richiesta. In Laravel, la configurazione nel RouteServiceProvider è:

// app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(120)
        ->by($request->ip())
        ->response(function () {
            return response()->json([
                'error' => 'rate_limit_exceeded',
                'message' => 'Troppe richieste. Riprova tra un minuto.',
                'retry_after' => 60,
            ], 429);
        });
});

Livello 2 - Rate limit per API key (difesa per cliente). Il secondo livello identifica il client non per IP ma per chiave API - un identificativo unico assegnato a ogni integratore. Un integratore legittimo con 5 sviluppatori che testano dall'ufficio (stesso IP) non viene penalizzato; uno scraper che usa 20 IP ma la stessa chiave API viene bloccato. La configurazione si sovrappone al primo livello:

RateLimiter::for('api-authenticated', function (Request $request) {
    $apiKey = $request->header('X-Api-Key');

    return [
        // Livello 1: per IP, permissivo
        Limit::perMinute(300)->by($request->ip()),
        // Livello 2: per API key, restrittivo per piano
        Limit::perMinute($this->getLimitForApiKey($apiKey))
            ->by($apiKey),
    ];
});

Il metodo getLimitForApiKey() restituisce un limite diverso per ogni piano: 60/minuto per il piano free, 300/minuto per il piano standard, 1.000/minuto per il piano premium. Questo rate limiting differenziato per piano è sia una misura di sicurezza sia un meccanismo di monetizzazione - se un integratore ha bisogno di più di 300 richieste al minuto, deve passare al piano premium.

Livello 3 - Rate limit per endpoint (protezione differenziata). Non tutti gli endpoint hanno lo stesso costo computazionale. L'endpoint GET /api/verifica-cf/{codice} è una query leggera su un indice - può sopportare 1.000 richieste al minuto. L'endpoint POST /api/report/genera è un'elaborazione pesante che richiede 2-3 secondi di CPU - 10 richieste al minuto sono il massimo sostenibile. Il rate limit per endpoint applica limiti diversi in base al costo dell'operazione:

// Route con rate limit differenziato per endpoint
Route::middleware('throttle:verifica')
    ->get('/api/verifica-cf/{codice}', VerificaCFController::class);

Route::middleware('throttle:report')
    ->post('/api/report/genera', GeneraReportController::class);

// Rate limiter per la verifica (leggero, limite alto)
RateLimiter::for('verifica', fn (Request $request) =>
    Limit::perMinute(600)->by($request->header('X-Api-Key') ?: $request->ip())
);

// Rate limiter per il report (pesante, limite basso)
RateLimiter::for('report', fn (Request $request) =>
    Limit::perMinute(10)->by($request->header('X-Api-Key') ?: $request->ip())
);

Livello 4 - Adaptive rate limiting (valvola di sicurezza globale). Il quarto livello è il più sofisticato: un rate limiter che scala automaticamente le restrizioni in base al carico corrente del server. Quando il load average supera il 70% della capacità o quando la listen queue di FPM supera i 10 worker in attesa, il rate limiter riduce i limiti di tutti i livelli del 50% - proteggendo il server dal sovraccarico indipendentemente da quanti client stanno inviando richieste. Questo livello richiede un middleware custom che legge le metriche del sistema da Redis (aggiornate ogni 10 secondi da un cronjob di monitoring) e modifica dinamicamente i limiti.

La logica è: in condizioni normali, il limite per IP è 300/minuto; quando il server è sotto carico (load average > 70%), il limite scende a 150/minuto; quando il server è in pericolo (load average > 90%), scende a 50/minuto. Questo approccio adattivo evita i downtime da sovraccarico senza richiedere un dimensionamento del server per il picco massimo teorico - il server si protegge automaticamente riducendo il throughput ammesso.

Redis come backend: perché il database non basta per il rate limiting

Il rate limiter di default di Laravel usa il driver di cache configurato - che nella maggior parte delle installazioni è file (filesystem) o database. Entrambi sono inadeguati per il rate limiting in produzione ad alto traffico. Il driver file richiede una syscall di lettura/scrittura per ogni verifica del rate limit - 300 richieste al minuto significano 600 operazioni su disco al minuto, con latenza di microsecondi su NVMe ma con rischio di lock contention su filesystem condivisi. Il driver database richiede una query SQL per ogni verifica - un overhead misurabile che aumenta il carico sul database applicativo proprio nel momento in cui il server è sotto pressione per l'attacco.

Redis è il backend ideale per il rate limiting perché le operazioni sono in-memory (latenza di microsecondi, nessun I/O su disco), atomiche (i comandi INCR e EXPIRE sono atomici per design, eliminando le race condition tra worker FPM concorrenti), e hanno un overhead minimo sul server applicativo. La configurazione in Laravel richiede Redis installato (tipicamente già presente per il caching e le code) e una riga nel file .env: CACHE_DRIVER=redis. Da quel momento, tutti i rate limiter usano Redis come backend, con una capacità di gestire decine di migliaia di verifiche al secondo senza impatto misurabile sulle prestazioni.

Un aspetto tecnico che ho dovuto gestire nel progetto del cliente è la consistenza del rate limiting in ambienti multi-server. Se l'API gira su tre istanze dietro un load balancer, il rate limiter su ogni istanza deve condividere lo stesso contatore - altrimenti un client può inviare 300 richieste a ciascuna delle tre istanze per un totale di 900 richieste al minuto, superando il limite di 300 previsto. Con Redis come backend, il contatore è condiviso tra tutte le istanze perché tutte puntano allo stesso server Redis - il rate limiting è globale indipendentemente da quale istanza riceve la richiesta. Se usi il driver file o database in un ambiente multi-server, il rate limiting è per istanza e un attaccante può triplicare il suo budget semplicemente distribuendo le richieste tra le tre istanze.

La dimensione dei dati in Redis per il rate limiting è trascurabile: ogni chiave di rate limit (un hash che combina IP, API key e endpoint) occupa circa 100 byte con un TTL uguale alla finestra temporale del rate limit. Per 10.000 client attivi con 3 livelli di rate limiting ciascuno, il consumo totale è di circa 3 MB - una frazione della RAM tipicamente allocata a Redis. I dati scadono automaticamente grazie al TTL, senza necessità di pulizia manuale.

Un ultimo dettaglio implementativo: configuro sempre un fallback nel caso in cui Redis sia temporaneamente irraggiungibile (riavvio, manutenzione, crash). Se Redis è down e il rate limiter non può verificare il contatore, il comportamento di default è permettere la richiesta - meglio servire qualche richiesta in più durante i 30 secondi di downtime di Redis che bloccare tutti gli utenti perché il sistema di protezione è temporaneamente non disponibile. Il fallback è un middleware che cattura l'eccezione di connessione Redis e logga un warning - l'alert sul canale di monitoring avvisa il team che Redis è down, e il rate limiting riprende automaticamente quando Redis torna online.

Header di risposta e comunicazione con gli integratori

Un aspetto spesso trascurato del rate limiting è la comunicazione: il client che riceve un 429 (Too Many Requests) deve sapere quanto aspettare prima di riprovare e quante richieste gli restano nel budget corrente. Laravel include automaticamente gli header X-RateLimit-Limit, X-RateLimit-Remaining e Retry-After nella risposta - ma per un'API pubblica con integratori esterni, questi header non sono sufficienti. Aggiungo header custom che comunicano il piano del cliente, il limite per endpoint, e il carico corrente del server, in modo che gli integratori possano implementare un backoff intelligente nel loro codice:

Gli integratori del cliente sono stati informati del nuovo sistema di rate limiting con una email tecnica che descriveva i limiti per piano, gli header di risposta da monitorare, e un esempio di implementazione del backoff nel loro codice. Il numero di richieste bloccate è sceso da 4.000/minuto (lo scraper) a meno di 50/minuto (client legittimi che occasionalmente superano il limite) nel primo giorno dopo l'implementazione - e le prestazioni dell'API sono tornate stabili con latenza p95 sotto i 200 ms anche durante i picchi di traffico. Ho descritto le misure complementari di protezione delle API nel mio articolo sull'hardening NIS2-ready per applicazioni Laravel, dove il rate limiting è una delle misure obbligatorie per la conformità alla direttiva. Se la tua API Laravel è esposta a Internet senza rate limiting o con il semplice throttle:60,1 di default, contattami per un assessment: in mezza giornata configuriamo il sistema multi-livello, testiamo con k6 il comportamento sotto carico, e definiamo i limiti per piano che bilanciano protezione e usabilità per i tuoi integratori.

Ultima modifica: