Alessandro Aili - WordPress Developer
Alessandro Aili - WordPress DeveloperPlay Alessandro Aili - WordPress DeveloperDrag Alessandro Aili - WordPress DeveloperView
Alessandro Aili - WordPress Developer
  • Homepage
  • Chi sono
  • Portfolio
  • Aree di Attività
    • Sviluppo di Siti Web Personalizzati
    • Ottimizzazione delle Prestazioni
    • Sicurezza del Sito Web
    • Aggiornamenti e Manutenzione
    • Sviluppo di Temi e Plugin Personalizzati
    • Migrazione e Trasferimento del Sito
  • Blog
  • Contatti
Alessandro Aili - WordPress Developer
Alessandro Aili - WordPress Developer Alessandro Aili - WordPress Developer
  • Homepage
  • Chi sono
  • Portfolio
  • Aree di Attività
    • Sviluppo di Siti Web Personalizzati
    • Ottimizzazione delle Prestazioni
    • Sicurezza del Sito Web
    • Aggiornamenti e Manutenzione
    • Sviluppo di Temi e Plugin Personalizzati
    • Migrazione e Trasferimento del Sito
  • Blog
  • Contatti
MENU Alessandro Aili - WordPress Developer
  • Home
  • Sicurezza
  • Blog
  • Proteggere il login di WordPress: le quattro porte, come limitare i tentativi e cosa conta davvero

Proteggere il login di WordPress: le quattro porte, come limitare i tentativi e cosa conta davvero

Alessandro Aili - WordPress Developer
Alessandro Aili WordPress Developer

Data di pubblicazione

17 Settembre 2026

Tempo di lettura

29 minuti

Categoria

Sicurezza

Commenti

0

Schema del login di WordPress: quattro porte che accettano una password — modulo di login, XML-RPC, REST API, moduli dei plugin — e un contatore dei tentativi davanti a tutte

Il login di WordPress non è una pagina: è quattro porte che accettano una password, più una che rivela i nomi utente. Quasi tutte le guide su come proteggerlo elencano le stesse sei difese — password lunghe, secondo fattore, limite ai tentativi, CAPTCHA, indirizzo nascosto, XML-RPC spento — e quasi nessuna dice dove ciascuna agisce. È il dettaglio che decide se funzionano: un limite ai tentativi che guarda solo wp-login.php lascia aperte tre porte su quattro, e un contatore che identifica il visitatore con l’indirizzo IP, dietro Cloudflare, blocca tutti o nessuno.

Questa guida va in quest’ordine: cosa succede davvero quando qualcuno preme «Accedi», quali sono le porte e quale campana suona ciascuna, cosa deve contare un limite ai tentativi perché serva a qualcosa, e poi come limitare i tentativi di login in WordPress a quattro livelli — plugin, codice tuo, server, edge — con la verifica dal terminale e il modo per rientrare se ti chiudi fuori da solo. In coda, le difese che agiscono prima e dopo il tentativo, e una tabella che dice porta per porta cosa copre cosa.

Una nota sulle fonti. Tutto quello che segue l’ho verificato il 17 settembre 2026 sul codice sorgente di WordPress 7.1, sulla documentazione ufficiale di WordPress, nginx, Apache e Cloudflare, sul sorgente dei sei plugin nominati (cinque scaricati lo stesso giorno, uno installato qui) e sulle linee guida NIST SP 800-63B-4. Il codice è stato eseguito, non solo riletto: le porte del login e il mu-plugin in un’installazione WordPress 7.1 vera (WordPress Playground), la configurazione di nginx 1.30.5 con PHP-FPM e i filtri di fail2ban 1.1.0 in due container Docker. Dove compare un numero di versione, accanto c’è la data.

Cosa succede quando qualcuno preme «Accedi»

Il modulo di wp-login.php manda nome utente e password a wp_signon(), che chiama wp_authenticate(). Questa funzione non verifica niente da sola: applica il filtro authenticate, e sono i callback agganciati a quel filtro a decidere. In un’installazione pulita sono quattro: tre a priorità 20 — wp_authenticate_username_password, wp_authenticate_email_password e wp_authenticate_application_password — e uno a priorità 99, wp_authenticate_spam_check, che conta solo nelle reti multisito. L’ho verificato stampando la catena dei filtri in un WordPress 7.1 di prova: quattro callback, in quell’ordine.

Se la catena restituisce un errore, wp_authenticate() fa scattare l’azione wp_login_failed, con il nome utente tentato e l’errore. È la campana che i plugin di sicurezza ascoltano per contare i tentativi, e conviene sapere due cose su come suona. Suona anche quando il nome utente non esiste (errore invalid_username) e quando l’email non esiste (invalid_email): l’ho misurato, tre tentativi, tre eventi. Non suona quando i campi sono vuoti — il caricamento della pagina di login, per il core, non è un tentativo.

Se la catena restituisce un utente, WordPress emette i cookie di autenticazione. Durano due giorni, o quattordici se hai spuntato «Ricordami» — i due valori sono nel codice di wp_set_auth_cookie() e si cambiano con il filtro auth_cookie_expiration. Dalla 4.0 ogni accesso è anche una sessione con un token proprio, ed è per questo che nel profilo esiste il pulsante «Scollegati da ogni postazione»: chiude le altre sessioni senza toccare la tua. Ci torno in fondo, perché è una difesa che quasi nessuno usa.

Un’ultima cosa sul confronto della password. Dalla 6.8 (aprile 2025) WordPress salva le password con bcrypt invece del vecchio phpass, e riscrive l’hash al primo accesso successivo all’aggiornamento — ho raccolto le novità di ciascuna release nella tabella delle versioni di WordPress. Per chi difende è una buona notizia con un rovescio: bcrypt è lento per progetto, quindi ogni tentativo sbagliato costa al server più di prima. È un motivo in più per fermare i tentativi prima del confronto, e vedrai che il posto per farlo esiste.

Le quattro porte per la password, e la quinta per il nome utente

Ho contato le porte provandole una per una con curl su un WordPress 7.1 in cui registravo ogni evento di autenticazione. Sono quattro quelle in cui una password viene confrontata con un hash, e non suonano tutte la stessa campana.

1. wp-login.php, e ogni modulo che ci posta sopra

È la porta che tutti conoscono, e l’unica che i bot trovano da soli. Un tentativo sbagliato suona wp_login_failed. Ci passa anche il modulo generato da wp_login_form() nel tema, che è solo un form con la stessa destinazione. Un dettaglio che vale la pena conoscere: WordPress controlla che il browser accetti i cookie dopo aver verificato la password, non prima. Un bot che non manda il cookie di prova riceve l’errore «i cookie sono bloccati», ma il tentativo è stato contato lo stesso — l’ho visto nei log della prova.

2. xmlrpc.php: una password per richiesta, dalla 4.4

Il file xmlrpc.php accetta le stesse credenziali del login su ogni metodo che le richiede, per esempio wp.getUsersBlogs. Passa da wp_authenticate(), quindi suona la stessa campana della porta 1, ma non ha CAPTCHA, non ha un indirizzo da nascondere e risponde con un messaggio uniforme — «Nome utente o password non corrette» — sia per l’utente inesistente sia per la password sbagliata.

Su questa porta circola un’affermazione che va corretta, perché la trovi ancora quasi ovunque: che con system.multicall un attaccante possa provare centinaia di password in una sola richiesta. Era vero, e non lo è più dal dicembre 2015: WordPress 4.4 ha introdotto un cambiamento per cui, dopo il primo errore di autenticazione in una richiesta XML-RPC, tutti i tentativi successivi della stessa richiesta vengono rifiutati senza essere verificati. L’ho provato: una multicall con dieci coppie di credenziali, di cui la settima corretta, restituisce dieci rifiuti e fa scattare un solo wp_login_failed. Il motivo per chiudere o limitare XML-RPC è un altro: una richiesta costa poco, non passa dal modulo di login, e un contatore che guarda solo l’indirizzo wp-login.php non la vede.

3. La REST API con le password applicazione: la porta che non suona la campana

Dalla 5.6 (dicembre 2020) WordPress ha le password applicazione: credenziali di 24 caratteri generate dal profilo utente, pensate per programmi e non per persone, che si usano con l’autenticazione HTTP Basic su qualunque richiesta alla REST API (e anche su XML-RPC). La password normale del sito su quella porta non vale: l’ho provato, risponde 401. Sono disponibili solo con HTTPS, e la porta si apre nel momento in cui un utente qualsiasi ne crea la prima dal proprio profilo — per impostazione predefinita possono farlo tutti i ruoli. Finché nessuno l’ha fatto, il core ignora del tutto l’intestazione Authorization.

Il punto che rende questa porta diversa dalle altre è nel sorgente: la funzione che valida le credenziali Basic sulla REST API, wp_validate_application_password(), chiama direttamente la verifica senza passare dal filtro authenticate e senza wp_authenticate(). Un tentativo sbagliato non fa suonare wp_login_failed: fa suonare un’azione diversa, application_password_failed_authentication, e la richiesta prosegue come anonima. Nella mia prova, una password applicazione sbagliata su GET /wp-json/wp/v2/posts ha ricevuto 200 — l’elenco pubblico degli articoli — e ha lasciato traccia solo su quella seconda azione. Ho scaricato e cercato nel sorgente di sei plugin che contano i tentativi di login (Limit Login Attempts Security 3.3.9, Wordfence 9.0.1, Kadence Security 10.0.4 e la versione Pro installata su questo sito, All-In-One Security 5.4.10, WP fail2ban 5.4.1): nessuno dei sei aggancia quell’azione. Ai loro contatori questa porta non esiste.

Va detto per onestà cosa questo comporta e cosa no. Indovinare una password applicazione è fuori portata: 24 caratteri alfanumerici casuali, oltre 142 bit di entropia secondo la guida ufficiale. Quello che manca è la visibilità: nessun conteggio, nessun blocco, nessuna notifica su una porta che qualunque utente può aprire e che — per progetto — non chiede il secondo fattore. La guida di integrazione lo dice esplicitamente: sono nate anche per «removes an obstacle for the inclusion of multi-factor authentication on interactive logins», cioè per lasciare alle integrazioni una strada che il secondo fattore non ferma. Se sul sito non ci sono integrazioni che le usano, la porta si chiude con una riga, add_filter( 'wp_is_application_passwords_available', '__return_false' );, che è la stessa scelta offerta dall’opzione dedicata di Wordfence. Se ci sono — un’app mobile, un client di pubblicazione, un frontend headless che legge le bozze — la si limita ai soli utenti che ne hanno bisogno con il filtro wp_is_application_passwords_available_for_user, e si conta anche la seconda campana. Il mu-plugin più avanti lo fa.

4. I moduli che chiamano wp_signon(): WooCommerce e le aree riservate

Il modulo «Il mio account» di WooCommerce non manda niente a wp-login.php: nel suo codice (class-wc-form-handler.php, metodo process_login()) chiama wp_signon() direttamente, e lo stesso fanno i plugin per le aree riservate e i moduli di accesso personalizzati. Passano dal filtro authenticate, quindi suonano wp_login_failed e un contatore scritto dentro WordPress li vede. Ma hanno un indirizzo diverso: un limite basato sull’URL — la regola sul server o sulla CDN che guarda wp-login.php, il plugin che nasconde la pagina di login — su questa porta non fa niente. Per un negozio WooCommerce è la porta principale, non un’eccezione.

La quinta: il modulo «Password dimenticata»

Qui nessuna password viene confrontata, e non suona nessuna campana di autenticazione — c’è un’azione a parte, lostpassword_post. Ma è una porta a tutti gli effetti, per due ragioni: dice se un account esiste (ci arrivo nella sezione su quello che il login rivela da solo) e, con l’email giusta, avvia un flusso che finisce con una password nuova. Merita le stesse protezioni anti-bot del login, e per il suo aspetto e il suo funzionamento merita una guida a sé, che sta in calendario.

Cosa deve contare un limite ai tentativi

WordPress, di suo, non conta niente: puoi sbagliare la password all’infinito, e il manuale ufficiale per gli amministratori non lo nasconde — dice di limitare i tentativi «at the edge (WAF/CDN) or the web server» e aggiunge che i plugin «still execute within PHP and thus consume some resources under heavy attack». Prima di scegliere dove mettere il contatore, però, conviene decidere cosa deve contare. Sono tre domande, e ognuna ha una risposta sbagliata molto diffusa.

Cos’è un tentativo

Un tentativo è un confronto fallito fra una password e un hash, su qualunque porta. Non è «una richiesta POST a wp-login.php»: quella misura vede la porta 1 e basta. Il contatore giusto ascolta wp_login_failed per le porte 1, 2 e 4, e application_password_failed_authentication per la 3. E deve contare una volta per richiesta: su XML-RPC, con le password applicazione attive, un errore fa suonare entrambe le campane, e un contatore ingenuo segna due. Me ne sono accorto scrivendo il mu-plugin di questa guida, perché contava doppio — è il genere di cosa che si vede solo eseguendo.

Chi è il visitatore

Quasi tutti i limiti identificano chi tenta con l’indirizzo IP, e lo leggono da $_SERVER['REMOTE_ADDR']. Dietro Cloudflare, o dietro qualunque proxy inverso, quel valore non è l’indirizzo del visitatore: è l’indirizzo del proxy, e l’IP vero viaggia in un’intestazione — per Cloudflare, CF-Connecting-IP. Il risultato lo descrive bene la documentazione stessa di Limit Login Attempts Security: «all users will get the same IP address. This also applies to bots and hackers. Therefore, locking one user will lead to locking everybody else out». Un bot sbaglia cinque volte, e il sito blocca l’indirizzo di Cloudflare: cioè tutti.

Ci sono tre modi per dare a WordPress l’indirizzo giusto, e il primo è quello buono. Sul server: nginx ha il modulo realip (set_real_ip_from + real_ip_header CF-Connecting-IP), Apache ha mod_remoteip (RemoteIPHeader CF-Connecting-IP + RemoteIPTrustedProxy); in entrambi i casi l’intestazione va accettata solo dagli indirizzi di Cloudflare, pubblicati in una lista che va tenuta aggiornata, altrimenti chiunque può dichiararsi qualcun altro con un’intestazione finta. Fatto questo, ogni plugin legge l’IP vero senza sapere che esiste un proxy. Nel plugin Cloudflare per WordPress, che riscrive REMOTE_ADDR quando la richiesta arriva da uno dei loro intervalli (l’ho letto nel suo codice: lo fa su plugins_loaded, quindi prima di qualunque conteggio). Nel plugin di sicurezza, che di solito ha un’impostazione apposta: «Trusted IP Origins» in Limit Login Attempts Security, «Proxy Detection» in Kadence Security. La verifica costa un minuto: apri il registro dei blocchi del plugin e guarda gli indirizzi. Se sono tutti uguali, o appartengono tutti a Cloudflare, il server non conosce l’IP vero e il tuo limite sta bloccando alla cieca.

Per indirizzo o per nome utente

Contare per IP ferma i bot rumorosi — molti tentativi dallo stesso indirizzo in poco tempo. Non ferma quelli distribuiti — come funzionano gli attacchi brute force distribuiti l’ho spiegato nella guida sugli attacchi — che ruotano gli indirizzi e restano sotto ogni soglia, né il password spraying, che prova poche password comuni su tanti nomi utente. Per quelli serve contare anche per account, ed è quello che chiedono le linee guida NIST: nella SP 800-63B-4 (luglio 2025), § 3.2.2, il verificatore «SHALL limit consecutive failed authentication attempts using a specific authenticator on a single subscriber account to no more than 100». Cento è un tetto, non un consiglio: la stessa sezione suggerisce ritardi crescenti e verifiche anti-bot prima della soglia.

Il rovescio del blocco per account lo descrive l’Authentication Cheat Sheet di OWASP: «care must be taken to prevent it from being used to cause a denial of service by locking out other users’ accounts». Chi conosce il tuo nome utente può chiuderti fuori sbagliando la password apposta. Kadence Security tiene le due soglie separate — nel modulo Local Brute Force il valore predefinito è 5 tentativi per indirizzo e 10 per nome utente in cinque minuti — e la scelta sensata è quella: una soglia bassa per IP, una più alta per account, mai il blocco permanente dell’account, e il modulo «Password dimenticata» che continua a funzionare anche durante il blocco.

Cosa succede alla soglia, e cosa il limite non fa

Alla soglia si blocca a tempo, con durata crescente alle recidive: venti minuti la prima volta, ventiquattro ore dopo quattro blocchi è la progressione predefinita di Limit Login Attempts Security, e funziona. Il messaggio di blocco dev’essere uguale per l’utente che esiste e per quello che non esiste. E il blocco va applicato prima del confronto della password, non dopo: il filtro wp_authenticate_user esiste apposta, scatta quando l’utente è stato trovato e la password non è ancora stata verificata, e un errore restituito lì salta il calcolo di bcrypt. Anche i tentativi falliti costano — è una delle cause di un sito lento per traffico automatizzato — e la differenza fra rifiutare prima o dopo il confronto, sotto attacco, si vede nel carico del server.

Quello che il limite non fa, e che nessuna soglia può fare: rendere inutile una password giusta. Contro il credential stuffing — la tua password presa da una violazione altrui e provata qui una volta sola — il contatore non scatta mai. Per quello ci sono il secondo fattore e le password uniche, nella parte finale di questa guida.

Come limitare i tentativi di login in WordPress: quattro livelli

I quattro livelli non si escludono: quello più vicino al visitatore ferma il traffico prima che costi, quello dentro WordPress è l’unico che vede tutte e quattro le porte. La combinazione che consiglio è due livelli — uno fuori da PHP, uno dentro — e la verifica in fondo vale per tutti.

Con il plugin di sicurezza che hai già

Se sul sito c’è già un plugin di sicurezza, il limite ai tentativi c’è già: non se ne installa un secondo. Quattro cose da controllare nelle impostazioni, qualunque sia il plugin. La soglia e la progressione: pochi tentativi, blocco breve, blocco lungo alle recidive. Il conteggio per nome utente, oltre che per IP, se il plugin lo offre. L’indirizzo che il plugin legge, se il sito è dietro Cloudflare o un proxy (vedi sopra). La lista dei tuoi indirizzi, se hai un IP fisso, per non chiuderti fuori da solo. I nomi, verificati sul repository il 17/09/2026:

  • Limit Login Attempts Security (3.3.9, oltre un milione di installazioni; fino a poco tempo fa si chiamava Limit Login Attempts Reloaded, e l’intestazione del file lo dice ancora): fa solo questo, ed è la scelta se non vuoi un plugin di sicurezza completo. Predefiniti: 4 tentativi, blocco di 20 minuti, dopo 4 blocchi 24 ore. Copre wp-login.php, XML-RPC e i moduli WooCommerce; ha l’impostazione «Trusted IP Origins» per i proxy e una modalità GDPR.
  • Wordfence Security (9.0.1): il limite è nella sezione Brute Force Protection, con il blocco immediato dei nomi utente inesistenti, il rifiuto delle password comparse in violazioni note e — nel modulo Login Security — passkey e 2FA. Ha anche un’opzione per disattivare del tutto le password applicazione.
  • Kadence Security (10.0.4; fino al 2023 iThemes Security, poi Solid Security): modulo Local Brute Force con le due soglie separate, e Proxy Detection nelle impostazioni globali. È il plugin che uso su questo sito, nella versione Pro.
  • All-In-One Security (AIOS) (5.4.10): limite ai tentativi con blocco a tempo, più CAPTCHA e 2FA.

Con tutti e quattro vale l’avvertenza della porta 3: contano le porte 1, 2 e 4, non i tentativi con password applicazione sulla REST API. Non è un difetto di un plugin, è una conseguenza di come il core valida quelle credenziali. Sui criteri per scegliere fra questi — manutenzione, storico delle vulnerabilità, quanto codice aggiungono — vale come si valuta un plugin di sicurezza prima di installarlo.

Con un mu-plugin di 74 righe

Se vuoi capire davvero cosa fa un limite ai tentativi — o se hai un sito piccolo e non vuoi un plugin di sicurezza — bastano 74 righe in wp-content/mu-plugins/. Questo file conta gli errori per indirizzo su tutte e quattro le porte, blocca l’indirizzo per venti minuti dopo cinque errori in un quarto d’ora, rifiuta i tentativi prima del confronto della password e scrive una riga nel log degli errori di PHP quando blocca, così fail2ban può leggerla. L’ho eseguito su WordPress 7.1 con i test che descrivo subito dopo.

<?php
/**
 * Plugin Name: Limite ai tentativi di login
 * Description: Conta i tentativi di accesso falliti per indirizzo IP su tutte le porte di WordPress (modulo di login, form nel tema, XML-RPC, REST API con password applicazione) e blocca l'indirizzo per un periodo dopo troppi errori. File in wp-content/mu-plugins/.
 */

// La regola: al massimo LT_MAX errori in LT_FINESTRA, poi blocco per LT_BLOCCO.
const LT_MAX      = 5;
const LT_FINESTRA = 15 * MINUTE_IN_SECONDS;
const LT_BLOCCO   = 20 * MINUTE_IN_SECONDS;

// L'indirizzo del client. REMOTE_ADDR è giusto solo se il server conosce l'IP vero:
// dietro Cloudflare o un altro proxy va ripristinato a livello di server, non qui.
function lt_ip() {
	return apply_filters( 'lt_ip', $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
}

function lt_chiave( $tipo ) {
	return 'lt_' . $tipo . '_' . md5( lt_ip() );
}

function lt_bloccato() {
	return (bool) get_transient( lt_chiave( 'blocco' ) );
}

function lt_errore() {
	return new WP_Error( 'too_many_attempts', 'Troppi tentativi di accesso da questo indirizzo. Riprova più tardi.' );
}

// Un tentativo fallito: si incrementa il contatore e, alla soglia, scatta il blocco.
function lt_fallito() {
	static $contato = false;
	if ( $contato || lt_bloccato() ) {
		return; // una richiesta conta una volta sola, anche se suona più di una campana
	}
	$contato = true;
	$errori = (int) get_transient( lt_chiave( 'errori' ) ) + 1;
	set_transient( lt_chiave( 'errori' ), $errori, LT_FINESTRA );
	if ( $errori >= LT_MAX ) {
		set_transient( lt_chiave( 'blocco' ), time(), LT_BLOCCO );
		delete_transient( lt_chiave( 'errori' ) );
		error_log( 'Limite tentativi di login: bloccato ' . lt_ip() ); // riga leggibile da fail2ban
	}
}

// Le due campane: il login classico (modulo, form nel tema, XML-RPC)…
add_action( 'wp_login_failed', 'lt_fallito' );
// …e le password applicazione, che sulla REST API non passano da wp_login_failed.
add_action( 'application_password_failed_authentication', 'lt_fallito' );

// Un accesso riuscito azzera il contatore.
add_action( 'wp_login', function () {
	delete_transient( lt_chiave( 'errori' ) );
} );

// Il blocco, in tre punti.
// 1. Prima che la password venga confrontata con l'hash, quando l'utente esiste.
add_filter( 'wp_authenticate_user', function ( $user ) {
	return lt_bloccato() ? lt_errore() : $user;
}, 1 );

// 2. In coda alla catena di autenticazione, per tutti gli altri casi (nome utente inesistente).
add_filter( 'authenticate', function ( $user, $username, $password ) {
	if ( lt_bloccato() && ( '' !== $username || '' !== $password ) ) {
		return lt_errore();
	}
	return $user;
}, 100, 3 );

// 3. Sulla REST API, dove il core non passa dal filtro authenticate: per l'IP bloccato
//    le password applicazione non sono disponibili, e il confronto non avviene proprio.
add_filter( 'wp_is_application_passwords_available', function ( $disponibili ) {
	return lt_bloccato() ? false : $disponibili;
} );

Sei prove, tutte con curl. Uno: sei password sbagliate di fila su wp-login.php — le prime cinque ricevono il normale «la password inserita non è corretta», la sesta «Troppi tentativi di accesso da questo indirizzo», e la password giusta, da quell’indirizzo, viene rifiutata con lo stesso messaggio. Due: dall’indirizzo bloccato, la password giusta via XML-RPC riceve «Nome utente o password non corrette», la password applicazione giusta sulla REST API riceve 401 senza che il confronto avvenga, e un modulo che chiama wp_signon() riceve l’errore too_many_attempts. Tre: il contatore è condiviso fra le porte — due errori via XML-RPC, due sulla REST API con password applicazione, uno da un modulo personalizzato, e al sesto tentativo tutto è chiuso. Quattro: una system.multicall con dieci tentativi conta uno. Cinque: tre errori seguiti da un accesso riuscito azzerano il contatore. Sei: la pagina di login aperta da un indirizzo bloccato non mostra nessun messaggio, perché i campi vuoti non sono un tentativo.

Tre scelte spiegate. Il blocco è in tre punti perché le porte sono tre percorsi diversi nel codice: wp_authenticate_user ferma l’utente esistente prima di bcrypt, il filtro authenticate in coda alla catena ferma tutto il resto, e wp_is_application_passwords_available spegne le password applicazione per l’indirizzo bloccato, che è l’unico modo di intercettare la porta 3 prima del confronto. Il conteggio è solo per IP: se vuoi anche quello per nome utente, la chiave del transient si costruisce con il primo argomento di wp_login_failed, e la soglia va più alta, per il motivo di OWASP visto sopra. E l’indirizzo viene da REMOTE_ADDR con un filtro per cambiarlo, di proposito: il posto giusto per ripristinare l’IP vero è il server, non il plugin.

Sul server: nginx e fail2ban

Il server ferma le richieste prima che PHP le veda, ed è il livello che il manuale di WordPress consiglia di preferire. Con nginx bastano il modulo limit_req e, se c’è un proxy davanti, il modulo realip. Questo blocco l’ho eseguito su nginx 1.30.5 con PHP-FPM dietro: dieci GET a wp-login.php passano tutti, perché la chiave è vuota per tutto ciò che non è un POST e nginx non conta le chiavi vuote; di otto POST di fila ne passano quattro — il primo più i tre di burst — e dal quinto la risposta è 429; xmlrpc.php condivide la stessa zona; e con due visitatori diversi dietro lo stesso proxy, distinti da CF-Connecting-IP, i contatori sono separati e PHP riceve in REMOTE_ADDR l’indirizzo vero. La riga fastcgi_pass è quella della tua configurazione.

# Nel blocco http { ... }

# Dietro Cloudflare l'IP del client arriva nell'intestazione CF-Connecting-IP: nginx lo sostituisce
# a $remote_addr, ma solo per le richieste che vengono dagli indirizzi elencati. Qui vanno gli intervalli
# pubblicati su www.cloudflare.com/ips (uno per riga), da tenere aggiornati. Senza proxy davanti, togli le due righe.
set_real_ip_from 173.245.48.0/20;
# ... gli altri intervalli di Cloudflare ...
real_ip_header   CF-Connecting-IP;

# Si contano solo i POST: per gli altri metodi la chiave è vuota, e nginx non conta le chiavi vuote.
map $request_method $chiave_login {
    default "";
    POST    $binary_remote_addr;
}
limit_req_zone $chiave_login zone=login:10m rate=6r/m;

# Nel blocco server { ... } del sito

location = /wp-login.php {
    limit_req zone=login burst=3 nodelay;   # una richiesta ogni 10 s, più tre di scorta; poi 429
    limit_req_status 429;                   # il predefinito sarebbe 503
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php-fpm.sock;   # o 127.0.0.1:9000: come nel resto della tua configurazione
}
location = /xmlrpc.php {
    limit_req zone=login burst=3 nodelay;
    limit_req_status 429;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

Due note. Il codice di risposta predefinito di limit_req è 503, che per Google significa «sito in difficoltà»: limit_req_status 429 dice la cosa giusta, «troppe richieste». E rate=6r/m con burst=3 nodelay significa una richiesta ogni dieci secondi più tre di scorta: un amministratore che sbaglia due volte non lo tocca; una raffica passa quattro volte e poi viene rifiutata finché il contatore non scende, di una richiesta ogni dieci secondi. Su Apache non esiste un equivalente nel core: il manuale rimanda a ModSecurity o al limite per indirizzo (Require ip su wp-login.php), e per l’IP vero c’è mod_remoteip.

fail2ban lavora da un’altra angolazione: legge un log e blocca a livello di firewall di sistema gli indirizzi che compaiono troppe volte, quindi la richiesta bloccata non arriva nemmeno a nginx. Ha bisogno di una riga da leggere, e ci sono due modi per dargliela. Il primo è il plugin WP fail2ban (5.4.1, aggiornato nell’aprile 2025 e dichiarato compatibile fino alla 6.8: funziona, ma guardalo prima di affidargli un sito), che scrive nel syslog righe come «Authentication failure for admin from 203.0.113.5» e fornisce i filtri pronti. Ho fatto girare i suoi wordpress-hard.conf e wordpress-soft.conf su fail2ban 1.1.0 con sei righe di prova: il filtro soft ne riconosce quattro (errori di password, utente inesistente, XML-RPC), quello hard una (enumerazione bloccata), e l’accesso riuscito non lo riconosce nessuno dei due, come deve essere. La configurazione del jail è nella documentazione del plugin. Il secondo modo è la riga che scrive il mu-plugin qui sopra, con un filtro di tre righe — eseguito anche questo, tre righe di log, due riconosciute (un IPv4 e un IPv6) e una scartata:

# /etc/fail2ban/filter.d/wordpress-limite.conf — riconosce la riga scritta dal mu-plugin nel log di PHP:
# [17-Sep-2026 16:02:11 UTC] Limite tentativi di login: bloccato 203.0.113.5
[Definition]
failregex = ^\[?\]?\s*Limite tentativi di login: bloccato <HOST>\s*$
ignoreregex =

# /etc/fail2ban/jail.d/wordpress-limite.conf — il jail: un blocco di WordPress = un ban di un'ora sul firewall
[wordpress-limite]
enabled  = true
filter   = wordpress-limite
logpath  = /var/log/php-fpm/www-error.log   # il file in cui PHP scrive error_log(): dipende dal server
maxretry = 1
findtime = 10m
bantime  = 1h
port     = http,https

All’edge: la regola di Cloudflare

Una regola di rate limiting su Cloudflare ferma le richieste prima ancora che arrivino al server: è il livello più economico sotto attacco. Ha però due limiti che conviene sapere prima di contarci. Il primo: conta richieste, non errori. Alla CDN non risulta se la password era giusta; per questo la regola va scritta sul metodo POST verso wp-login.php (e xmlrpc.php, se lo tieni), con una soglia che un amministratore non raggiunge digitando. Il secondo sono i piani: sul piano gratuito hai una regola, contata per indirizzo IP, con finestra di conteggio e durata del blocco fisse a dieci secondi; il minuto di finestra arriva con il piano Pro, i dieci minuti di blocco con il Business, e contare per codice di risposta — cioè solo gli errori — dal Business in su (documentazione Cloudflare, aggiornata al 25 agosto 2026). Dieci secondi bastano a spezzare una raffica; non bastano da soli contro un bot che rallenta apposta, ed è per questo che l’edge è un livello in più, non l’unico. Quali due regole mettere su Cloudflare e cosa fare mentre un attacco è in corso l’ho scritto nella guida agli attacchi DDoS e brute force.

Come verificare che funzioni

Qualunque livello tu abbia scelto, la verifica è la stessa e si fa dal terminale, da una connessione che puoi permetterti di vedere bloccata per venti minuti. Tre comandi, uno per porta; il nome utente è di proposito inesistente, così non tocchi il contatore per account di nessuno. Il primo manda sei tentativi al modulo di login e stampa il messaggio d’errore di ciascuno; se il limite funziona, entro il sesto il messaggio cambia.

SITO=https://www.tuosito.it
UTENTE=utente-di-prova
PASSWORD_APPLICAZIONE='abcd efgh ijkl mnop qrst uvwx'

# 1. Porta 1: sei tentativi con un nome utente che non esiste, stampando il messaggio d'errore di ciascuno.
for i in 1 2 3 4 5 6; do
  curl -s -b "wordpress_test_cookie=WP%20Cookie%20check" \
    -d "log=utente-che-non-esiste&pwd=prova$i&wp-submit=1&testcookie=1" \
    "$SITO/wp-login.php" | grep -o 'id=.\?login_error.\{0,220\}' | sed 's/^[^>]*>//; s/<[^>]*>//g'
done

# 2. Porta 2: la stessa coppia via XML-RPC. Il messaggio è uno solo, quindi qui si guarda il contatore:
#    con il limite dentro WordPress la richiesta viene contata (e, a blocco attivo, rifiutata senza verifica).
curl -s -H 'Content-Type: text/xml' \
  --data '<?xml version="1.0"?><methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><string>utente-che-non-esiste</string></value></param><param><value><string>prova</string></value></param></params></methodCall>' \
  "$SITO/xmlrpc.php" | grep -o '<string>[^<]*'

# 3. Porta 3: con una password applicazione VALIDA di un utente di prova. Prima del blocco risponde 200,
#    dopo 401 (o 404, se hai nascosto la rotta ai visitatori): il confronto non avviene più.
curl -s -o /dev/null -w '%{http_code}\n' -u "$UTENTE:$PASSWORD_APPLICAZIONE" "$SITO/wp-json/wp/v2/users/me"

Se il limite è sul server o sull’edge, il segnale è il codice di stato (429 da nginx, la pagina di blocco di Cloudflare) invece del messaggio. Se dopo il blocco la richiesta a xmlrpc.php risponde ancora «Nome utente o password non corrette» dopo aver verificato davvero la password — cioè se il limite è solo sull’URL — hai trovato la porta aperta. E se il blocco non scatta mai da nessuna porta, prima di dare la colpa al plugin controlla l’indirizzo che il plugin vede: è il caso del proxy, di nuovo.

Se ti chiudi fuori

Succede, di solito il giorno in cui attivi il limite e provi «se funziona» con il tuo nome utente. Tre vie d’uscita, dalla più semplice. Aspetta: il blocco è a tempo — 20 minuti con Limit Login Attempts Security, 15 con Kadence Security, nelle impostazioni predefinite — e passa da solo. Con un’eccezione: Kadence Security, dopo tre blocchi, mette l’indirizzo nella lista dei banditi in modo permanente (anche questo è predefinito), e da lì si toglie solo a mano, dalla scheda Ban Users. Se hai WP-CLI, il blocco del mu-plugin è un transient, e wp transient delete --all li cancella tutti (i plugin salvano i blocchi in tabelle o opzioni proprie: ognuno ha la sua voce «unlock»). Via SFTP, in ultima istanza: rinomina il file in mu-plugins, o la cartella del plugin, entra, e rimettilo a posto. Per non arrivarci: metti il tuo indirizzo nella lista dei fidati se è fisso, e fai le prove con un nome utente che non esiste.

Quello che il login rivela da solo

Metà di ogni credenziale è il nome utente, e WordPress lo regala in quattro posti. Il primo è il modulo di login stesso: se il nome non esiste, il messaggio è «il nome utente X non è registrato su questo sito»; se esiste e la password è sbagliata, «la password inserita per il nome utente X non è corretta». Due messaggi diversi, quindi una risposta alla domanda «questo account esiste?». Il secondo è «Password dimenticata»: per un nome inesistente risponde «non ci sono account con quel nome utente o quell’indirizzo email». Il terzo è la REST API, che su /wp-json/wp/v2/users elenca gli utenti che hanno pubblicato qualcosa, con lo slug — che per impostazione predefinita coincide con il nome utente. Il quarto è ?author=1, che WordPress trasforma in un 301 verso l’archivio autore, /author/slug/: è uno dei redirect che WordPress fa da solo, e l’indirizzo di arrivo contiene lo stesso slug. Ho verificato tutti e quattro sulla 7.1: sono il comportamento previsto, non un baco.

Le correzioni, in ordine di resa. I due messaggi si uniformano con il filtro login_errors — è quello che fa l’opzione «non rivelare gli utenti validi» di Wordfence — e per XML-RPC non serve, perché il messaggio è già uno solo. L’elenco utenti della REST API si toglie ai visitatori non autenticati con il filtro rest_endpoints, lasciandolo a chi è loggato perché l’editor lo usa per scegliere l’autore; su questo sito l’ho fatto così, ed è la strada anche quando il plugin di sicurezza non ha un’opzione sufficientemente mirata (l’opzione «REST API: Restricted Access» di Kadence Security chiude anche la rotta dei commenti, che questo tema usa). Per ?author=1 la correzione vera non è bloccare l’archivio, che è una pagina pubblica legittima: è fare in modo che lo slug non sia il nome utente, cambiando il user_nicename (con WP-CLI, wp user update) e reindirizzando il vecchio archivio al nuovo. E il nome utente admin resta la prima cosa da cambiare, come dice il controllo 6 della checklist sicurezza WordPress in 25 controlli.

Le difese che agiscono prima e dopo il tentativo

Il limite ai tentativi agisce durante. Le altre difese del login si dividono in due gruppi a seconda di quando intervengono, e metterle in fila così chiarisce cosa manca quando ne manca una.

Prima: anti-bot, indirizzo e server

Un sistema anti-bot sul modulo — reCAPTCHA v3, Cloudflare Turnstile — scarta gli script prima che una password venga verificata, e va messo anche su registrazione, recupero password e commenti; le alternative a reCAPTCHA e la configurazione di Turnstile hanno ciascuna una guida in calendario. Due avvertenze: copre solo la porta 1 (e la 4, se il modulo lo include), e va scelto fra quelli che non chiedono di risolvere un test, perché i CAPTCHA visivi escludono chi naviga da tastiera. Nascondere l’indirizzo di login toglie rumore, non sicurezza: i bot che cercano wp-login.php non lo trovano, ma le porte 2, 3 e 4 non passano da lì. Il livello server — limitare wp-login.php ai tuoi indirizzi, o proteggerlo con una password HTTP — è il più forte contro la porta 1, con una regola dal manuale di WordPress: applicalo a wp-login.php, non all’intera cartella wp-admin, che ospita admin-ajax.php e rompe i plugin che lo usano. Come farlo su nginx sarà una guida a parte.

Alla soglia della password

WordPress mostra un indicatore di robustezza quando imposti una password, ma non impone niente: sotto il campo c’è la casella «Conferma l’uso di una password debole», e basta spuntarla. Quello che vale la pena imporre lo dice la stessa SP 800-63B-4 (§ 3.1.1.2), e va nella direzione opposta alle abitudini: almeno 15 caratteri se la password è l’unico fattore (otto se c’è il secondo), nessuna regola di composizione — «SHALL NOT impose other composition rules» —, nessuna scadenza periodica, e il confronto con una lista di password comuni o già trapelate. Le ultime due cose le fanno alcuni plugin di sicurezza (Wordfence rifiuta le password comparse in violazioni note; Kadence Security ha un modulo analogo); le prime due sono una policy che decidi tu. Sul nome utente e sugli account che non servono più valgono i controlli 6 e 7 della checklist di sicurezza.

Dopo: il secondo fattore e le sessioni

Il secondo fattore cambia cosa vale una password giusta: niente, da sola. È l’unica difesa contro il credential stuffing, e il core non ce l’ha — lo dice il manuale ufficiale, «WordPress core does not ship 2FA» — quindi passa da un plugin: il plugin Two Factor (0.16.0, marzo 2026, fra i cui autori c’è WordPress.org) fa app TOTP, email e codici di riserva; i plugin di sicurezza citati sopra lo includono, e Wordfence aggiunge le passkey, che il manuale consiglia come login resistente al phishing. Due cose da sapere quando lo attivi: le password applicazione non passano dal secondo fattore, per progetto, e quindi vanno spente o limitate ai soli utenti che le usano; e i codici di riserva vanno conservati fuori dal sito, perché il primo telefono perso diventa un problema di accesso.

Le sessioni sono la difesa dimenticata. Un accesso riuscito vale due giorni, o quattordici con «Ricordami»; un cookie rubato vale altrettanto. Nel profilo utente, «Scollegati da ogni postazione» chiude tutte le altre sessioni del tuo account; cambiare le chiavi di sicurezza in wp-config.php invalida le sessioni di tutti, ed è la prima cosa da fare quando sospetti che qualcuno sia entrato — l’ordine completo delle operazioni è nella sezione su cosa fare se l’attacco è in corso. E se il codice malevolo è arrivato dentro un plugin, il login non c’entra più: è un attacco alla catena di fornitura, e si difende altrove.

Porta per porta: cosa copre cosa

La tabella riassume quello che ho verificato: per ogni porta, quale campana suona e quali difese la vedono. Le caselle vuote sono le ragioni per cui una difesa sola non basta mai.

PortaCampanaLimite nel plugin / mu-pluginLimite sull’URL (server, edge)Anti-bot sul moduloURL nascosto2FA
1. wp-login.php e i moduli che ci postanowp_login_failedsìsìsìsìsì
2. xmlrpc.phpwp_login_failedsìsì, se la regola lo include——dipende dal plugin
3. REST API con password applicazioneapplication_password_failed_authenticationsolo se aggancia la seconda campana (il mu-plugin sì, i sei plugin verificati no)———— (per progetto)
4. Moduli che chiamano wp_signon()wp_login_failedsì—solo se il modulo lo include—sì
5. «Password dimenticata»lostpassword_post—sì, se la regola include l’azionesìsì—

Su questo sito, per dire come si legge: la porta 1 è nascosta (l’indirizzo standard risponde con un redirect verso una pagina che non esiste) e conta i tentativi con il plugin di sicurezza, che legge l’indirizzo vero e non quello di Cloudflare — l’ho ricontrollato nel registro dei blocchi scrivendo questa guida; la porta 2 risponde 403 dal server, prima di PHP; la porta 3 è chiusa alla radice, perché non ho integrazioni che usino password applicazione; la porta 4 non esiste, perché non ci sono moduli di accesso nel tema; l’elenco utenti della REST API risponde 404 ai visitatori e l’archivio autore non porta il nome utente. Sono cinque righe, e ognuna corrisponde a una casella della tabella.

Domande frequenti

Quanti tentativi di login permette WordPress?

Infiniti. Il core non conta i tentativi falliti e non blocca nessuno: si limita a far scattare un’azione a ogni errore, e lascia il conteggio a chi la ascolta. Il manuale ufficiale per gli amministratori consiglia di limitare i tentativi sul web server o sulla CDN, e di usare un plugin quando quei livelli non sono disponibili, perché un plugin lavora in PHP e consuma risorse anche quando rifiuta.

Un limite ai tentativi ferma un attacco brute force?

Ferma quelli rumorosi: molti tentativi dallo stesso indirizzo in poco tempo. Non ferma quelli distribuiti su migliaia di indirizzi, né il password spraying, né una password corretta presa da una violazione altrui e provata una volta sola. Per il primo caso serve contare anche per nome utente, per l’ultimo serve il secondo fattore. Il limite è la difesa che costa meno e toglie di mezzo il traffico più rumoroso, non quella che chiude la partita.

Perché il plugin blocca tutti, o nessuno, dietro Cloudflare?

Perché il server vede l’indirizzo di Cloudflare al posto di quello del visitatore, e il plugin conta gli errori su quell’indirizzo unico: dopo pochi tentativi di un bot blocca l’indirizzo del proxy, cioè tutti, oppure — se esclude i proxy — nessuno. L’indirizzo vero arriva nell’intestazione CF-Connecting-IP e va ripristinato sul server (modulo realip di nginx o mod_remoteip di Apache, accettando l’intestazione solo dagli intervalli di Cloudflare), oppure con l’impostazione per i proxy del plugin. Il registro dei blocchi dice subito se il problema c’è: gli indirizzi bloccati sono tutti uguali.

XML-RPC permette ancora centinaia di password in una sola richiesta?

No, dal dicembre 2015. WordPress 4.4 ha cambiato il server XML-RPC in modo che, dopo il primo errore di autenticazione in una richiesta, tutti i tentativi successivi della stessa richiesta vengano rifiutati senza essere verificati, anche dentro system.multicall. Una richiesta prova quindi una password sola. XML-RPC resta una porta da chiudere se non la usi, ma per un motivo diverso: non ha CAPTCHA, non ha un indirizzo da nascondere e un limite scritto sull’URL di login non la vede.

Le password applicazione passano dal limite ai tentativi?

Di solito no. Sulla REST API il core le valida senza passare dal filtro authenticate, quindi un tentativo sbagliato non fa scattare l’azione che i plugin contano, ma un’azione diversa, che i sei plugin verificati per questa guida non ascoltano. Indovinarle è impraticabile, perché sono 24 caratteri casuali; il problema è che nessuno le sorveglia e che non chiedono il secondo fattore. Se non hai integrazioni che le usano, disattivale con il filtro apposito; se le hai, limitale agli utenti che ne hanno bisogno e conta anche quella seconda azione, come fa il mu-plugin di questa guida.

Mi sono chiuso fuori dal mio sito: come rientro?

Prima di tutto aspettando: i blocchi sono a tempo, e nelle impostazioni predefinite dei plugin il primo dura minuti, non ore. Se hai WP-CLI, il blocco del mu-plugin è un transient e si cancella con wp transient delete, mentre i plugin hanno una voce per sbloccare un indirizzo nelle loro impostazioni. Se non puoi aspettare e non hai la riga di comando, via SFTP rinomini il file del mu-plugin o la cartella del plugin, entri, e li rimetti a posto. Per non arrivarci: metti il tuo indirizzo nella lista dei fidati, se è fisso, e fai le prove con un nome utente che non esiste.

Da qui in avanti

Se devi ricordare una cosa sola: prima di scegliere dove mettere il limite, chiediti cosa conta e chi vede. Un contatore che ascolta le campane giuste vede quattro porte; uno che guarda un indirizzo ne vede una. Un contatore che legge l’IP vero ferma i bot; uno che legge l’IP del proxy ferma te. Poi due livelli — uno fuori da PHP, uno dentro — la verifica con tre comandi, e la stessa verifica ripetuta dopo ogni aggiornamento del plugin di sicurezza, perché le impostazioni cambiano nome e le porte no.

Il resto è routine: il registro dei blocchi da guardare ogni mese, gli account che non servono più da togliere, il secondo fattore da rendere obbligatorio e non facoltativo. Sta tutto nel piano di manutenzione del sito, e il momento giusto per impostarlo è il primo giorno, quando installi e configuri WordPress e non c’è ancora niente da perdere.

Se preferisci che lo faccia io

Verificare le quattro porte del tuo sito, mettere il limite al livello giusto per il tuo hosting, sistemare l’indirizzo vero dietro la CDN e rendere obbligatorio il secondo fattore è una parte del lavoro che faccio sulla sicurezza dei siti WordPress. Se vuoi che me ne occupi io, scrivimi: partiamo dalle porte, non da un elenco di plugin.

Indice dei contenuti

  • Cosa succede quando qualcuno preme «Accedi»
  • Le quattro porte per la password, e la quinta per il nome utente
  • Cosa deve contare un limite ai tentativi
  • Come limitare i tentativi di login in WordPress: quattro livelli
  • Quello che il login rivela da solo
  • Le difese che agiscono prima e dopo il tentativo
  • Porta per porta: cosa copre cosa
  • Domande frequenti
  • Da qui in avanti
  • Se preferisci che lo faccia io

Tag in Evidenza

HardeningLoginsicurezza WordPressWordPress

Condividi


Lascia un commento Annulla risposta

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Alessandro Aili - WordPress Developer Alessandro Aili - WordPress Developer
Sono un WordPress developer che affronta tutte le tue sfide online.
Skills
  • Sviluppo di Siti Web Personalizzati
  • Ottimizzazione delle Prestazioni
  • Sicurezza del Sito Web
  • Aggiornamenti e Manutenzione
  • Sviluppo di Temi e Plugin Personalizzati
  • Migrazione e Trasferimento del Sito
© Copyright 2026 Alessandro Aili
  • Mappa del sito
  • Privacy Policy
  • Cookie Policy
  • Preferenze cookie
Alessandro Aili - WordPress Developer