Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

[Security] Dispatch RateLimitExceededEvent from LoginThrottlingListener - #65628

#65628
Open
mariecharles wants to merge 1 commit into
symfony:8.2symfony/symfony:8.2from
mariecharles:feat/rate-limit-exceeded-event-from-login-throttlingmariecharles/symfony:feat/rate-limit-exceeded-event-from-login-throttlingCopy head branch name to clipboard
Open

[Security] Dispatch RateLimitExceededEvent from LoginThrottlingListener#65628
mariecharles wants to merge 1 commit into
symfony:8.2symfony/symfony:8.2from
mariecharles:feat/rate-limit-exceeded-event-from-login-throttlingmariecharles/symfony:feat/rate-limit-exceeded-event-from-login-throttlingCopy head branch name to clipboard

Conversation

@mariecharles

Copy link
Copy Markdown
Contributor
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues
License MIT

When login throttling rejects an attempt, LoginThrottlingListener throws a TooManyLoginAttemptsAuthenticationException and nothing else is exposed.

This dispatches now a Symfony\Component\RateLimiter\Event\RateLimitExceededEvent right before the exception is thrown, mirroring the addition made to RateLimitAttributeListener.

This is informational only. The rejection always happens but the event gives now visibility into it, providing a way to log it, trigger an alert or measure it.

@mariecharles
mariecharles force-pushed the feat/rate-limit-exceeded-event-from-login-throttling branch from ed19088 to 2ca39cb Compare August 24, 2026 12:16

@nicolas-grekas nicolas-grekas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this. The direction is right: #[RateLimit] has this hook and login throttling does not. Three points before it can go in.

The key does not name what was rejected. DefaultLoginRateLimiter consumes two limiters, a global one keyed on the IP and a local one keyed on the username plus the IP, and AbstractRequestRateLimiter returns the lower of the two. The listener always reports <username>-<ip>. In a breadth-first attack (one IP, a new username each attempt) only the global limiter rejects:

attempt 5: REJECTED, event key='user5-192.168.1.0' limit=5 remaining=0
attempt 6: REJECTED, event key='user6-192.168.1.0' limit=5 remaining=0
attempt 7: REJECTED, event key='user7-192.168.1.0' limit=5 remaining=0

limit=5 is the global limiter, yet the key names a username that limiter never saw. The real keys here are login-rTViA0.m and login-fc_wc5jm; neither matches, and the username is not lowercased the way the local limiter does it. A consumer grouping on the key gets one entry per attempt and never learns that the IP is blocked. getKey() documents itself as "the key that was consumed, when known", and here it is not known, so please pass no key at all.

Please pass the limiter name instead. LoginThrottlingFactory::createAuthenticator() already has $config['limiter'] (security.login_throttling.<firewall>.limiter) and already calls replaceArgument(). That gives consumers something exact to attribute on, and it names the firewall too.

One point for the docs. With the default peekable limiter, the RateLimit carried by the event has isAccepted() === true; the rejection comes from 0 === getRemainingTokens(). So the natural check if (!$event->getRateLimit()->isAccepted()) silently drops every login-throttling event. Please say so in the documentation pull request.

Two smaller things while you are in there:

  • service('event_dispatcher') should be ->nullOnInvalid(), matching how the same optional argument is wired for ContextListener and SwitchUserListener in security_listeners.php.
  • The unit test passes the dispatcher in by hand, so it cannot tell the global dispatcher from the firewall one. Wiring security.event_dispatcher.<firewall> by mistake would stay green, because RegisterGlobalSecurityEventListenersPass does not bubble this event. A functional test in FormLoginTest covers that.

Last thing: the description says nothing else exposes the rejection. LoginFailureEvent already carries it, with the firewall name, the request, the passport and the retry-after, and it bubbles to the global dispatcher. What is new here is the RateLimit object and the hook shared with #[RateLimit]. Worth rewording, so that reviewers weigh the right claim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.