Protect your Symfony login against brute force with login_throttling

A few years ago, I wrote a post about protecting a Symfony login form against brute force by writing your own event subscribers (in french): count failed logins in a cache, then block the IP. Since Symfony 5.2, this is built into the Security component with the login_throttling option in the firewall. This post shows how to enable it, what it really does, and how to adapt it: a per-account limit against botnets, an IP whitelist and production settings.

Tested with Symfony 7.3 and Symfony 8.1 on PHP 8.4 (November 2026). Differences between versions are written next to each feature.

TL;DR: install symfony/rate-limiter and add login_throttling to the firewall with max_attempts and interval (the default, 1 minute, is too short). Symfony then limits failed logins per username + IP and per IP. Against a botnet, add a per-username limit with your own limiter (limiter option).

Enable login_throttling

Throttling relies on the Rate Limiter component, so you need to install it if it's not already installed:

composer require symfony/rate-limiter

It is enabled in the firewall, next to the authenticator (form_login, json_login, or a custom authenticator that sets a UserBadge):

security:
    firewalls:
        main:
            form_login:
                login_path: login
                check_path: login
            # or, for an API:
            # json_login:
            #     check_path: /login
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'

Without options, max_attempts is 5 and interval is 1 minute: a bot can try 300 passwords per hour on one account. To mitigate this, you should use a longer interval; 15 minutes is a good compromise for a user who really mistyped their password.

Once the limit is reached, the login is refused even with the right password. Symfony throws a TooManyLoginAttemptsAuthenticationException: with json_login, the API answers 401 (not 429) with {"error":"Too many failed login attempts, please try again in 15 minutes."}; with form_login, the error is displayed like any other login error. Symfony ships translations of the message in the security domain:

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

What login_throttling really does

Symfony creates two fixed_window limiters for the firewall:

  • username + IP: max_attempts failures per interval. It stops the classic case, a bot trying passwords on one account.
  • IP only: 5 × max_attempts failures (25 by default). It stops a bot trying a common password on many accounts from the same IP.

Only failures count, Symfony checks the limit without consuming a token before authentication, and consumes one only when the login fails. Keys are hashed with a secret: neither the username nor the IP is stored in clear in the cache.

A successful login resets the username + IP counter, but not the per-IP counter. Be careful: this is only true since Symfony 6.4.46, 7.4.19 and 8.1.7. On earlier versions, including 7.3 and 8.0 which are no longer maintained, a successful login resets nothing.

Against a botnet: add a per-account limit

If the attacker has many IPs (a botnet), a per-IP limit is useless. Both default limiters have the same weakness, because each IP only makes a few attempts. The answer is a third limit, per username, whatever the IP. We need to use the limiter option in security configuration, this option accepts your own service.
We need to declare the default rate limiter and a new limiter to protect against the botnet:

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'

Then we need to write the class that will do the job, we can use AbstractRequestRateLimiter class. This consumes on every limiter returned by getLimiters() and keeps the most restrictive one.

<?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);
    }
}

The factories are injected by name ($loginIpLimiter for login_ip, and so on). Before Symfony 7.3, replace RateLimiterFactoryInterface with RateLimiterFactory. The reset() method is only called since 6.4.46, 7.4.19 and 8.1.7, as for the default limiter. Then plug the service into the firewall, instead of max_attempts and interval:

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

Testing with Symfony 7.3 and 8.1, with a wrong password sent from 25 different IPs on the same account: the first 20 attempts return Invalid credentials, the next 5 Too many failed login attempts, please try again in 60 minutes. Another account, from the same IPs, logs in normally. From a single IP, on 55 different usernames, the block starts after 50 attempts.

The downside of a per-account limit: anyone can lock a user's account by sending 20 wrong passwords, and the real user can no longer log in for an hour, even from their usual IP. Keep the limit high enough, the interval short, and provide a way to unlock an account: reset() on the limiter from a command or an admin action, or a passwordless login link (login_link).

Exclude IPs from throttling (whitelist)

login_throttling has no whitelist option. To exclude the office IPs or a monitoring tool that logs in often, decorate the limiter that Symfony creates for the firewall (security.login_throttling.<firewall>.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

The decorator must implement PeekableRequestRateLimiterInterface, like the original limiter: this is what makes Symfony check the limit before authentication and count only failures. With the plain RequestRateLimiterInterface, every attempt would be counted, even successful ones. Tested on 7.3 and 8.1: after 8 wrong passwords, a whitelisted IP (single or in the range) still gets Invalid credentials and logs in with the right password; the next IP, not in the list, is blocked as usual. #[AsDecorator] and #[AutowireDecorated] exist since Symfony 6.1.

This decorator only applies to the default limiter. With the limiter created before, it's simpler. We need to inject env var in constructor and check the IP in the getLimiters() method. If the IP is included in the whitelist, just return an empty array then the AbstractRequestRateLimiter will uses a NoLimiter.

In production

  • IP behind a proxy: configure framework.trusted_proxies. Without it, getClientIp() returns the load balancer IP: all users share the per-IP counter, and the whitelist, if it contains that IP, disables throttling for everybody. Conversely, trusting too many proxies lets an attacker spoof an IP with the X-Forwarded-For header.
  • Storage: counters are stored in the cache.rate_limiter pool, based on cache.app. With several servers, use a shared pool (Redis), otherwise each server counts on its own. Since Symfony 7.4, login_throttling accepts cache_pool and storage_service directly. With older version you need to use a custom service like the one we created before and configure each limiter of framework.rate_limiter the cache_pool option.
  • Lock: since Symfony 7.4, lock_factory defaults to auto and uses the Lock component when it is installed, so that simultaneous attempts do not slip through. On 7.3, the default is null: add lock_factory: lock.factory.
  • At the system level: throttling still starts PHP and Symfony for each attempt. To cut a massive attack earlier, ban IPs with fail2ban from your logs (see how to unban, and ban, an IP with fail2ban) or limit the login route in Nginx (limit_req).

To rate limit the rest of your application (API, slow routes), see protect your application with the Symfony Rate Limiter component.

FAQ

Why is the user blocked when their password is right?

It's on purpose: once the limit is reached, Symfony refuses the login before even checking the password, otherwise a bot would know it found the right one. The block lasts until the end of the window (interval).

Does login_throttling return 401 instead of 429?

Yes, it's an authentication error (TooManyLoginAttemptsAuthenticationException), handled like a wrong password. To return a 429, listen to LoginFailureEvent: if $event->getException() is a TooManyLoginAttemptsAuthenticationException, replace the response with $event->setResponse().

How do I unlock a user?

With the default limiter, wait for the end of the interval or clear the cache.rate_limiter pool (bin/console cache:pool:clear cache.rate_limiter, which unlocks everybody). With your own limiter, call reset() on the right factory, for example $loginUserLimiter->create($hash)->reset().

Do I still need a captcha?

Not for brute force: throttling is enough. A captcha can help against mass account creation, but it degrades the experience of every user.

Official documentation: limiting login attempts and the Rate Limiter component.

Add a comment