Login throttling with Symfony and multiple server instances

Login throttling with Symfony and multiple server instances
Photo by Alex Blăjan / Unsplash

Login throttling is possible with Symfony out of the box since 5.2. But the default configuration doesn't work as soon as you have multiple server instances.

Why? Because it stores the relevant data on the local filesystem.

So all we need to do is to exchange the storage layer with a shared one. How to do this? Lets take a few steps back:

The login throttling is a feature of the framework bundle and uses a combination of multiple Symfony components. The framework bundle uses the rate limiter component to setup a custom request limiter. The rate limiter in turn stores the data into a cache pool through the cache component. And as default it configures a filesystem adapter to do so.

So we start the other way round and configure our own cache pool. We need a shared one. As we already have an active PDO connection for our doctrine entities, we will use the PDO adapter to configure a shared cache pool through the database.

framework:
  cache:
    default_pdo_provider: 'doctrine.dbal.default_connection'

    pools:
      login_throttling.cache:
        adapter: cache.adapter.pdo

We put the configuration into the config/packages/cache.yaml.

With our cache pool we now are able to create the necessary rate limiters in the config/packages/rate_limiter.yaml file (which might not exist yet).

framework:
  rate_limiter:
    username_ip_login:
      policy: token_bucket
      limit: 5
      rate:
        amount: 1
        interval: '5 minutes'
      cache_pool: login_throttling.cache

    ip_login:
      policy: sliding_window
      limit: 50
      interval: '15 minutes'
      cache_pool: login_throttling.cache

One limiter checks for the combination of username and IP address and the other one is only interested in the IP address.

In this example, a user can try to login incorrectly for 5 times before the request will be rejected automatically. 5 minutes after the first login attempt the user will get another try and have to wait another 5 minutes for the next one. See the token bucket policy for more information.

The IP validation is configured with the sliding window policy. In the example, there can be a total of 50 attempts through an IP within a 15 minute window.

To connect those components we need to setup a custom service instance and adapt our security configuration. We will use the rate limiter Symfony uses by default and create a custom service instance like this:

services:
  app.login_rate_limiter:
    class: Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter
    arguments:
      $globalFactory: '@limiter.ip_login'
      $localFactory: '@limiter.username_ip_login'

And within our security.yaml we configure this new service for login_throttling:

security:
  firewalls:
    login:
      login_throttling:
        limiter: app.login_rate_limiter

Of course a PDO adapter isn't the fastest solution and not really a viable solution for usual rate limiting concerns (like API endpoint limiting). Depending on your infrastructure there might be better alternatives. Symfony supports a wide array of adapter options out of the box. And you could always build your own if needed.