Rate limiting avanzato in Laravel 12: proteggere API e form da abusi
Qualche mese fa, su un'API Laravel 12 (PHP 8.4, Nginx davanti a PHP-FPM) che gestisco per una piattaforma marketplace con migliaia di utenti business, il monitoraggio ha registrato un picco di 41.000 richieste in sei minuti verso un singolo endpoint di ricerca, originate da circa 180 indirizzi IP. La campagna è andata avanti per ore: quei sei minuti sono solo la finestra in cui l'ho intercettata, e a quel ritmo ogni IP accumula più di 2.000 richieste in meno di un'ora. Non era un attacco DDoS nel senso classico: era uno scraper distribuito, educato quanto basta da non far scattare gli allarmi di rete, abbastanza aggressivo da occupare i worker PHP-FPM e degradare i tempi di risposta per gli utenti paganti. Il firewall non lo vedeva, perché ogni singolo IP restava sotto soglia. E l'applicazione era configurata come nella maggior parte dei tutorial: throttle:api a 60 richieste al minuto, e nient'altro.
Quell'incidente racconta la verità sul rate limiting: la configurazione canonica è un cancello, non una strategia. In questa guida costruisco una difesa progressiva con gli strumenti nativi di Laravel 12, dal limiter nominato fino alle finestre multiple su Redis, passando per i form pubblici e le risposte 429 fatte bene.
Perché throttle:api da solo non ferma uno scraper distribuito?
Perché conta le richieste con una chiave sola e una finestra sola: 60 al minuto per utente autenticato, o per IP sul traffico anonimo. Uno scraper distribuito su 180 indirizzi dispone così di 10.800 richieste al minuto restando formalmente nei limiti, mentre lo stesso limite penalizza già un'azienda i cui cento dipendenti escono con un solo IP pubblico. La difesa efficace tratta il rate limiting su tre dimensioni separate: la chiave (chi stai contando), la finestra (su quale intervallo conti) e la risposta (cosa succede a chi sfora).
Prima i fondamentali. Il rate limiting di Laravel ruota attorno a due componenti: il facade RateLimiter, che definisce i limiter nominati, e il middleware throttle, che li applica alle rotte. In Laravel 12 i limiter si dichiarano nel metodo boot() di AppServiceProvider, come da documentazione ufficiale:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}Il middleware si aggancia nel file bootstrap/app.php, che dalla ristrutturazione di Laravel 11 in poi è il punto unico di configurazione dell'application kernel:
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleApi();
})Un dettaglio che molti scoprono in produzione: in Laravel 11 e 12 il throttling delle API è opt-in. Senza la chiamata a throttleApi() e senza un limiter api definito, il gruppo di rotte API non ha alcun limite. Il "default" da 60 al minuto che si cita spesso è in realtà la configurazione canonica dei tutorial, non un comportamento del framework.
Ultimo chiarimento di perimetro: se davanti all'applicazione hai già un gateway che fa throttling di primo livello, come Kong (caso che ho trattato nell'articolo sull'API gateway Kong per microservizi PHP), i due livelli non sono alternativi. Il gateway protegge l'infrastruttura con limiti grossolani per IP; l'applicazione protegge la logica di business con limiti fini per utente, piano e azione.
La chiave di throttling conta più del numero
La maggior parte delle configurazioni che ho revisionato negli anni sbaglia prima di tutto la chiave. La chiave deve identificare l'unità economica che consuma il servizio: l'utente autenticato, poi il token o la API key, e solo come ultima spiaggia l'indirizzo IP, che per il traffico aziendale dietro NAT è un'informazione quasi inutile.
RateLimiter::for('api', function (Request $request) {
if ($user = $request->user()) {
return Limit::perMinute(120)->by('user:'.$user->id);
}
return Limit::perMinute(20)->by('ip:'.$request->ip());
});Nota l'asimmetria voluta: gli autenticati hanno un limite sei volte più alto degli anonimi. È una scelta di prodotto travestita da scelta tecnica, e funziona in entrambe le direzioni: il traffico anonimo (dove vive lo scraping) viene strozzato, mentre i clienti reali non percepiscono alcun limite. Sullo stesso principio si costruiscono i limiti per piano tariffario, che in un contesto SaaS sono il caso d'uso più frequente:
RateLimiter::for('api', function (Request $request) {
return $request->user()?->plan === 'enterprise'
? Limit::perMinute(600)->by('user:'.$request->user()->id)
: Limit::perMinute(60)->by('user:'.($request->user()?->id ?: $request->ip()));
});Il prefisso esplicito nella chiave (user:, ip:) non è decorativo: evita collisioni tra spazi di nomi diversi quando gli stessi limiter convivono sullo stesso store di cache, e rende i contatori leggibili quando vai a ispezionarli a mano su Redis durante un incidente.
La scelta della chiave è il cuore degli interventi che faccio sulle API dei clienti, tra hardening applicativo e tuning dell'infrastruttura: se vuoi capire come imposto questo tipo di lavoro, trovi chi sono e come lavoro sul sito.
Finestre multiple: il limite singolo è sempre sbagliato
Un limite unico per minuto ha un difetto strutturale: o è troppo basso per i picchi legittimi, o è troppo alto per gli abusi prolungati. Sessanta richieste al minuto significano 86.400 richieste al giorno: nessun utente umano le fa, ma uno scraper paziente sì, restando sempre formalmente nei limiti.
Laravel permette di restituire un array di limiti dallo stesso limiter, valutati ciascuno con il proprio contatore e la propria finestra:
RateLimiter::for('search', function (Request $request) {
$key = $request->user()?->id ?: $request->ip();
return [
Limit::perSecond(3)->by('s:'.$key),
Limit::perMinute(60)->by('m:'.$key),
Limit::perDay(2000)->by('d:'.$key),
];
});Questa combinazione codifica tre affermazioni distinte: un essere umano non fa più di 3 ricerche al secondo (anti burst), né più di 60 al minuto (anti loop), né più di 2.000 al giorno (anti harvesting). Lo scraper dell'incidente in apertura è il bersaglio esatto della terza riga: nei sei minuti del picco ogni IP restava sotto i limiti al minuto, ma la campagna durava da ore, e a quel ritmo ogni singolo IP supera le 2.000 richieste giornaliere entro la prima ora. Il contatore per giorno li avrebbe spenti uno dopo l'altro, in silenzio, senza toccare nessun utente legittimo. Da quando ho introdotto le finestre giornaliere su quell'API, gli episodi di harvesting si contano sulle dita di una mano, tutti interrotti automaticamente.
Il limite per secondo merita una nota di versione: Limit::perSecond() esiste da Laravel 11 in avanti, quindi su codebase più vecchie la finestra minima resta il minuto. Per finestre così brevi l'accuratezza del backend di cache diventa critica, e qui entra in gioco Redis.
Redis e l'atomicità sotto carico
Il middleware throttle standard funziona con qualunque store di cache, ma c'è una sottigliezza che sotto carico reale diventa un problema: verifica e incremento del contatore sono due operazioni separate (prima tooManyAttempts(), poi hit()), su qualunque store. Due richieste concorrenti possono superare il controllo insieme e incrementare dopo: un classico check-then-act non atomico. Con lo store su file anche il singolo incremento è un read-then-write senza lock; quello su database protegge l'incremento con una transazione, ma il ciclo del middleware resta non atomico. Su un endpoint che riceve centinaia di richieste al secondo, lo sforamento sistematico del limite nominale è misurabile.
Laravel offre la soluzione dedicata: ThrottleRequestsWithRedis, una variante del middleware che salta lo strato di cache, parla direttamente con Redis ed esegue verifica e incremento in un unico script Lua, atomico per costruzione. Si attiva in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleWithRedis();
})Da quel momento tutti i middleware throttle usano Redis in modo nativo, a prescindere dal driver di cache di default dell'applicazione. Nei miei load test con k6 su un'API Laravel 12 dietro Nginx (ne ho documentato la metodologia in questo articolo sul performance testing con k6), la variante Redis ha tenuto il limite nominale con uno scarto inferiore all'1% a 300 richieste al secondo concorrenti, dove il middleware standard su store database lasciava passare anche il 12% di richieste oltre soglia nei burst.
Sul dimensionamento di Redis vale una regola che vedo spesso ignorata: i contatori di throttling meritano un'istanza dedicata, separata dalla cache applicativa, così un flush della cache non azzera anche i limiti (ho descritto questa separazione degli store nella guida al caching multilivello in Laravel). E siccome ogni contatore nasce con un TTL pari alla propria finestra, la memoria dell'istanza è naturalmente limitata: configurala noeviction (o al massimo volatile-ttl). Una policy LRU che sfratta i contatori sotto pressione di memoria significa limiti che si azzerano proprio nel momento dell'abuso, cioè un sistema che fallisce aperto quando serve chiuso.
Form pubblici: lo spam non è un problema di CAPTCHA
I form di contatto, registrazione e commento sono l'altro fronte, e qui il rate limiting è spesso più efficace del CAPTCHA, oltre che infinitamente meno ostile per l'utente. Un form di contatto legittimo viene inviato una volta, forse due se l'utente si accorge di un refuso. Un bot lo invia a raffica.
RateLimiter::for('contact-form', function (Request $request) {
return [
Limit::perMinute(2)->by('cf-ip:'.$request->ip()),
Limit::perHour(5)->by('cf-ip:'.$request->ip()),
Limit::perDay(3)->by('cf-mail:'.mb_strtolower((string) $request->input('email'))),
];
});La terza riga è quella interessante: limita per indirizzo email dichiarato, non per IP. Un bot che ruota gli IP ma riusa lo stesso mittente viene fermato comunque. La chiave su un input controllato dall'attaccante ha però un costo che va dichiarato: tre invii con l'email di una vittima la bloccano per un giorno. Per un form di contatto è un compromesso accettabile, il danno potenziale è una mail in meno, non un account fuori uso; per il login, come vedremo tra poco, la stessa scelta sarebbe un errore. Sul marketplace citato in apertura, la combinazione di throttling per email e honeypot ha ridotto lo spam dei form da una cinquantina di invii al giorno a meno di uno: l'ho introdotta subito dopo l'incidente, e da allora il customer care non ha segnalato un solo falso positivo.
Per il login il rate limiting è anche una difesa contro il credential stuffing, e la chiave giusta non è l'email da sola né l'IP da solo. Limitare solo per email permette a un attaccante di bloccare l'account di una vittima (denial of service mirato); limitare solo per IP non ferma gli attacchi distribuiti. Il pattern che uso, simile a quello adottato da Laravel Fortify, combina le due cose:
RateLimiter::for('login', function (Request $request) {
$email = mb_strtolower((string) $request->input('email'));
return Limit::perMinute(5)->by('login:'.$email.'|'.$request->ip());
});Cinque tentativi al minuto per coppia email più IP: il brute force su un singolo account muore subito, e un attaccante distribuito che prova la stessa password su mille account incontra comunque i limiti globali per IP degli altri limiter.
Risposte 429 che i client capiscono
Quando un limite scatta, Laravel risponde 429 Too Many Requests con gli header Retry-After, X-RateLimit-Limit e X-RateLimit-Remaining. Il comportamento di default è corretto ma anonimo, e per una API pubblica l'anonimato costa caro in ticket di supporto. Ogni limiter può personalizzare la risposta con response():
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'error' => 'rate_limited',
'message' => 'Hai superato il limite di richieste. Riprova tra '.$headers['Retry-After'].' secondi.',
'retry_after' => (int) $headers['Retry-After'],
], 429, $headers);
});
});Tre regole che applico sempre. Primo: il corpo della risposta deve avere un codice di errore stabile e machine-readable (rate_limited), perché i client lo gestiscano programmaticamente invece di fare retry alla cieca. Secondo: Retry-After va sempre propagato, perché i client HTTP seri (e gli SDK generati) lo rispettano automaticamente. Terzo: il messaggio non deve rivelare la struttura interna dei limiti; dire "riprova tra 40 secondi" va bene, elencare le soglie per finestra è informazione utile solo a chi ti sta attaccando.
C'è poi il throttling fuori dal ciclo HTTP: job in coda che chiamano API esterne, comandi Artisan, webhook in uscita. Per i job Laravel ha un middleware di coda dedicato, Illuminate\Queue\Middleware\RateLimited, che riusa i limiter nominati e rilascia automaticamente il job quando il limite è saturo: è la prima opzione da considerare. Il facade resta la via manuale quando vuoi controllare il ritardo di release o throttlare fuori dalle code:
$executed = RateLimiter::attempt(
'mailchimp-sync:'.$account->id,
maxAttempts: 10,
callback: fn () => $this->pushBatch($account),
decaySeconds: 60,
);
if (! $executed) {
return $this->release(30);
}RateLimiter::attempt() incapsula il ciclo completo (verifica, incremento, esecuzione) e si sposa bene con release() delle code: il job che trova il limite saturo si riaccoda con un ritardo invece di fallire, e il consumo verso il servizio esterno resta piatto anche quando la coda è piena.
Osservabilità: sapere chi stai limitando
Un sistema di rate limiting senza osservabilità è un firewall senza log: funziona finché non devi capire perché un cliente importante si lamenta. Il minimo sindacale è registrare ogni sforamento con la chiave, la rotta e il limiter coinvolto, e il punto di aggancio più pulito è la risposta personalizzata vista sopra, dove un log strutturato costa una riga.
Su quei log faccio due analisi ricorrenti. La distribuzione degli sforamenti per chiave separa gli abusi dai limiti tarati male: poche chiavi con migliaia di hit sono un attacco, molte chiavi con pochi hit ciascuna sono un limite troppo stretto per l'uso reale. Il rapporto tra 429 serviti e richieste totali per rotta dice invece se la protezione sta lavorando o se è ornamentale: un endpoint di ricerca pubblico sano, nella mia esperienza, vive tra lo 0,5% e il 2% di richieste limitate, e sopra il 5% c'è quasi sempre un attacco in corso oppure un client rotto in retry loop.
Vale anche il contrario: monitorare i contatori vicini alla soglia permette di alzare i limiti prima che i clienti legittimi li tocchino. Il rate limiting non è una punizione, è un contratto sul consumo: come ogni contratto va rinegoziato quando l'uso reale cambia.
Dove fermarsi
La tentazione, dopo un incidente, è di mettere un limiter su tutto. Resisti. Ogni limiter è stato condiviso: va mantenuto, documentato per i consumatori dell'API e considerato nei test di carico. Dieci limiter sovrapposti e non documentati sono debito tecnico con l'aggravante di mordere i clienti in produzione. La struttura che ho costruito in questa guida copre la quasi totalità dei casi che ho incontrato in vent'anni di backend, dagli e-commerce ad alto traffico alle API B2B con SLA contrattuali.
Se la tua applicazione Laravel è in produzione con throttle:api e nient'altro, il consiglio pratico è di cominciare dalla telemetria: una settimana di log sugli sforamenti e sui pattern di consumo reale vale più di qualunque numero copiato da un tutorial, e ti dice esattamente dove i limiti servono e quanto devono essere larghi. Se invece hai un'API che sta già incassando traffico che non riconosci, o un form che è diventato un bersaglio, contattami: di solito tra la diagnosi e la prima contromisura in produzione passano ore, non settimane.