Protéger le login Symfony contre la force brute avec login_throttling

En 2016, j'expliquais dans protéger son authentification Symfony 3 contre les attaques par brute force comment compter les échecs de connexion en cache et bloquer une IP avec deux event subscribers. Depuis Symfony 5.2, tout cela est intégré au composant Security : une option login_throttling dans le pare-feu suffit. Cet article montre comment l'activer, ce qu'elle fait vraiment, et comment l'adapter : limite par compte contre un botnet, liste blanche, production.

Testé avec Symfony 7.3 et Symfony 8.1 sous PHP 8.4 (novembre 2026). Les différences entre versions sont indiquées à chaque fois.

En bref : installez symfony/rate-limiter et ajoutez login_throttling au pare-feu avec max_attempts et interval (la valeur par défaut, 1 minute, est trop courte). Symfony limite alors les échecs par identifiant + IP et par IP. Contre un botnet, ajoutez une limite par identifiant avec votre propre limiteur (option limiter).

Activer login_throttling

Le throttling s'appuie sur le composant Rate Limiter, il faut dont l'installer si ce n'est pas déjà fait :

composer require symfony/rate-limiter

Il s'active dans le pare-feu, à côté de l'authentificateur (form_login, json_login, ou un authentificateur personnalisé qui pose un UserBadge) :

security:
    firewalls:
        main:
            form_login:
                login_path: login
                check_path: login
            # ou, pour une API :
            # json_login:
            #     check_path: /login
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'

Sans option, max_attempts vaut 5 et interval vaut 1 minute : un robot peut donc tester 300 mots de passe par heure sur un compte. Mettez une durée plus longue, 15 minutes est un bon compromis pour un utilisateur qui s'est vraiment trompé.

Une fois la limite atteinte, la connexion est refusée même avec le bon mot de passe. Symfony lève une TooManyLoginAttemptsAuthenticationException : avec json_login, l'API répond 401 (et non 429) avec {"error":"Too many failed login attempts, please try again in 15 minutes."} ; avec form_login, l'erreur s'affiche comme les autres erreurs de connexion. Symfony fournit la traduction française dans le domaine security :

{% if error %}
    <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}

Ce que fait vraiment login_throttling

Symfony crée deux limiteurs fixed_window pour le pare-feu :

  • identifiant + IP : max_attempts échecs par interval. Il bloque le cas classique, un robot qui teste des mots de passe sur un compte.
  • IP seule : 5 × max_attempts échecs (25 par défaut). Il bloque un robot qui teste un mot de passe courant sur beaucoup de comptes depuis la même IP.

Seuls les échecs comptent, Symfony vérifie la limite sans consommer de jeton avant l'authentification, et n'en consomme un qu'en cas d'échec. Les clés sont hachées avec un secret : ni l'identifiant ni l'IP ne sont stockés en clair dans le cache.

Une connexion réussie remet à zéro le compteur identifiant + IP, mais pas le compteur par IP. Attention, ce n'est le cas que depuis Symfony 6.4.46, 7.4.19 et 8.1.7. Sur les versions précédentes, dont 7.3 et 8.0 qui ne sont plus maintenues, une réussite ne remet pas le compteur à 0.

Contre un botnet : ajouter une limite par compte

Un lecteur de l'article de 2016 le faisait remarquer : si l'attaquant dispose de beaucoup d'IP (un botnet), une limite par IP ne sert à rien. Les deux limiteurs par défaut ont le même défaut, car chaque IP ne fait que quelques tentatives.
La parade est une troisième limite, par identifiant, quelle que soit l'IP. Cela implique de remplacer la config avec l'option limiter qui accepte votre propre service. Il faut donc définir le nouveau limiter mais également les 2 limiters cinsruit atomatiquement par la config du composant Security :

framework:
    rate_limiter:
        login_ip:
            policy: 'fixed_window'
            limit: 50
            interval: '15 minutes'
        login_user_ip:
            policy: 'fixed_window'
            limit: 5
            interval: '15 minutes'
        login_user:
            policy: 'fixed_window'
            limit: 20
            interval: '1 hour'

Ensuite on définit la classe qui sera utilisé par le composant sécurity, pour simplifier on peut se baser sur AbstractRequestRateLimiter. Cette classe consomme sur chaque limiteur renvoyé par getLimiters() et garde le plus restrictif.

<?php
 
declare(strict_types=1);
 
namespace App\Security\RateLimiting;
 
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
 
final class LoginRateLimiter extends AbstractRequestRateLimiter
{
    public function __construct(
        // Symfony 7.3+: RateLimiterFactoryInterface. Symfony 5.2 to 7.2: RateLimiterFactory
        private readonly RateLimiterFactoryInterface $loginIpLimiter,
        private readonly RateLimiterFactoryInterface $loginUserIpLimiter,
        private readonly RateLimiterFactoryInterface $loginUserLimiter,
        #[Autowire('%kernel.secret%')]
        private readonly string $secret,
    ) {
    }
 
    protected function getLimiters(Request $request): array
    {
        $ip = $request->getClientIp() ?? '';
        $username = mb_strtolower((string) $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME, ''));
 
        return [
            $this->loginIpLimiter->create($this->hash($ip)),                   // many usernames from one IP
            $this->loginUserIpLimiter->create($this->hash($username.'-'.$ip)), // one username from one IP
            $this->loginUserLimiter->create($this->hash($username)),           // one username from many IPs (botnet)
        ];
    }
 
    // on a successful login, only forget the failures of this username from this IP
    public function reset(Request $request): void
    {
        $ip = $request->getClientIp() ?? '';
        $username = mb_strtolower((string) $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME, ''));
 
        $this->loginUserIpLimiter->create($this->hash($username.'-'.$ip))->reset();
    }
 
    // keys are hashed so that usernames and IPs are not stored in clear in the cache
    private function hash(string $data): string
    {
        return hash_hmac('sha256', $data, $this->secret);
    }
}

Les factories sont injectées par nom ($loginIpLimiter pour login_ip, etc.). Avant Symfony 7.3, remplacez RateLimiterFactoryInterface par RateLimiterFactory. La méthode reset() n'est appelée qu'à partir des versions 6.4.46, 7.4.19 et 8.1.7, comme pour le limiteur par défaut. Il reste à brancher le service dans le pare-feu, à la place de max_attempts et interval :

security:
    firewalls:
        main:
            form_login:
                login_path: login
                check_path: login
            login_throttling:
                limiter: App\Security\RateLimiting\LoginRateLimiter

En testant avec Symfony 7.3 et 8.1, avec un mauvais mot de passe envoyé depuis 25 IP différentes sur le même compte : les 20 premières tentatives renvoient Invalid credentials, les 5 suivantes Too many failed login attempts, please try again in 60 minutes. Un autre compte, depuis les mêmes IP, se connecte normalement. Depuis une seule IP, sur 55 identifiants différents, le blocage arrive après 50 tentatives.

Le revers de la limite par compte : n'importe qui peut bloquer le compte d'un utilisateur en envoyant 20 mauvais mots de passe, et le vrai utilisateur ne peut plus se connecter pendant une heure, même depuis son IP habituelle. Gardez une limite assez haute, une durée courte, et prévoyez un moyen de débloquer un compte : reset() sur le limiteur depuis une commande ou une action d'administration, ou une connexion par lien magique (login_link).

Exclure des IP du throttling (liste blanche)

login_throttling n'a pas d'option de liste blanche. Pour exclure les IP du bureau ou d'un outil de supervision qui se connecte souvent, on peut décorer le limiteur que Symfony crée pour le pare-feu (security.login_throttling.<pare-feu>.limiter) :

<?php
 
declare(strict_types=1);
 
namespace App\Security\RateLimiting;
 
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\RateLimiter\PeekableRequestRateLimiterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\Policy\NoLimiter;
use Symfony\Component\RateLimiter\RateLimit;
 
// "main" is the name of the firewall
#[AsDecorator('security.login_throttling.main.limiter')]
final class WhitelistLoginRateLimiter implements PeekableRequestRateLimiterInterface
{
    /**
     * @param list<string> $whitelist IPs or CIDR ranges, e.g. 10.0.0.0/8
     */
    public function __construct(
        #[AutowireDecorated]
        private readonly PeekableRequestRateLimiterInterface $inner,
        #[Autowire('%env(csv:LOGIN_THROTTLING_WHITELIST)%')]
        private readonly array $whitelist,
    ) {
    }
 
    public function consume(Request $request): RateLimit
    {
        return $this->isWhitelisted($request) ? (new NoLimiter())->consume() : $this->inner->consume($request);
    }
 
    public function peek(Request $request): RateLimit
    {
        return $this->isWhitelisted($request) ? (new NoLimiter())->consume(0) : $this->inner->peek($request);
    }
 
    public function reset(Request $request): void
    {
        $this->inner->reset($request);
    }
 
    private function isWhitelisted(Request $request): bool
    {
        $ip = $request->getClientIp();
 
        return null !== $ip && IpUtils::checkIp($ip, $this->whitelist);
    }
}
# .env.local
LOGIN_THROTTLING_WHITELIST=192.168.1.10,10.20.0.0/16

Le décorateur doit implémenter PeekableRequestRateLimiterInterface, comme le limiteur d'origine : c'est ce qui fait que Symfony vérifie la limite avant l'authentification et ne compte que les échecs. Avec la simple RequestRateLimiterInterface, chaque tentative serait comptée, même réussie. Testé sur 7.3 et 8.1 : après 8 mauvais mots de passe, une IP de la liste (seule ou dans la plage) reçoit toujours Invalid credentials et se connecte avec le bon mot de passe ; l'IP voisine, hors liste, est bloquée normalement. #[AsDecorator] et #[AutowireDecorated] existent depuis Symfony 6.1.

Ce décorateur ne s'applique qu'au limiteur par défaut. Avec le limiteur créé à la section précédente, c'est plus simple, pas besoin de décorateur, il suffit d'injecter la variable d'environnement et de faire la vérification d'IP dans le méthode getLimiters() et renvoyer un tableau vide pour une IP de la liste dans ce cas AbstractRequestRateLimiter utilise un NoLimiter.

En production

  • IP derrière un proxy : configurez framework.trusted_proxies. Sans ça, getClientIp() renvoie l'IP du load balancer : tous les utilisateurs partagent le compteur par IP, et la liste blanche, si elle contient cette IP, désactive le throttling pour tout le monde. À l'inverse, des proxys de confiance trop larges permettent d'usurper une IP avec l'en-tête X-Forwarded-For.
  • Stockage : les compteurs vont dans le pool cache.rate_limiter, basé sur cache.app. Avec plusieurs serveurs, il faut un pool partagé (Redis), sinon chaque serveur compte de son côté. Depuis Symfony 7.4, login_throttling accepte directement cache_pool et storage_service ; avant, il faut déclarer les limiteurs dans la config pour utiliser un cache Redis et utiliser un service custom comme celui fait au-dessus.
  • Verrou : depuis Symfony 7.4, lock_factory vaut auto et utilise le composant Lock s'il est installé, pour que des tentatives simultanées ne passent pas entre les mailles. En 7.3, la valeur par défaut est null il faut donc ajouter lock_factory: lock.factory sans oublier d'installer le composant composer require symfony/lock.
  • Au niveau du système : le throttling démarre toujours PHP et Symfony à chaque tentative. Pour couper une attaque massive plus tôt, bannissez les IP avec fail2ban à partir des logs (voir comment débannir, et bannir, une IP avec fail2ban) ou limitez la route de connexion dans Nginx (limit_req).

Pour limiter le reste de l'application (API, routes lentes), voir l'article : protéger son application avec le composant Rate Limiter de Symfony.

FAQ

Pourquoi l'utilisateur est bloqué alors que son mot de passe est bon ?

C'est voulu : une fois la limite atteinte, Symfony refuse la connexion avant même de vérifier le mot de passe, sinon un robot saurait qu'il a trouvé le bon. Le blocage dure jusqu'à la fin de la fenêtre (interval).

login_throttling renvoie une 401, pas une 429 ?

Oui, c'est une erreur d'authentification (TooManyLoginAttemptsAuthenticationException), traitée comme un mauvais mot de passe. Pour renvoyer une 429, écoutez LoginFailureEvent : si $event->getException() est une TooManyLoginAttemptsAuthenticationException, remplacez la réponse avec $event->setResponse().

Comment débloquer un utilisateur ?

Avec le limiteur par défaut, attendez la fin de l'intervalle ou videz le pool cache.rate_limiter (bin/console cache:pool:clear cache.rate_limiter, qui débloque tout le monde). Avec votre propre limiteur, appelez reset() sur la factory concernée, par exemple $loginUserLimiter->create($hash)->reset().

Faut-il encore un captcha ?

Pas pour la force brute : le throttling suffit. Un captcha peut servir contre la création de comptes en masse, mais il dégrade l'expérience de tous les utilisateurs.

Documentation officielle (en anglais) : limiter les tentatives de connexion et le composant Rate Limiter.

Ajouter un commentaire