Notifying Users of Multiple Failed Login Attempts
This article is a follow-up to Implementing Suspicious Login Detection & "Wasn't Me" in Laravel . This time, the goal is to notify users when someone might be trying to brute-force their account. As always with Laravel, it's easy to implement.
Before We Start
Make sure you're using Laravel's default rate limiter. If you've customized it in config/fortify.php, reset the login limiter to null:
'limiters' => [
'login' => null,
'two-factor' => 'two-factor',
],
This ensures Laravel uses its default throttling behavior, which triggers a Lockout event after 5 failed login attempts within 60 seconds.
Note: You can modify how many login attempts a user may have within a period of time inside FortifyServiceProvider.
Creating the Listener
Let's create a listener for the Lockout event, but first, a test:
php artisan make:test SendLockoutNotificationTest
First, I want to verify that our listener is actually attached to the Lockout event:
it('is attached to auth lockout event', function () {
Event::fake();
Event::assertListening(
Lockout::class,
SendLockoutNotification::class
);
});
Now let's create the SendLockoutNotification listener:
php artisan make:listener SendLockoutNotification --event=\\Illuminate\\Auth\\Events\\Lockout
Greenlight.
Sending the Notification
The next step is simple: notify the user about multiple failed login attempts. Let's update our test:
it('sends notification mail to user', function () {
Notification::fake();
$user = User::factory()->create();
$request = Request::create(fake()->url, 'GET', [
'email' => $user->email,
'password' => fake()->password,
]);
event(new Lockout($request));
Notification::assertSentTo(
$user,
MultipleFailedLoginAttempts::class
);
});
We should also make sure we don't send notifications (or leak information) when someone tries to brute-force a non-existent email:
it('does not send notification for non-existent email', function () {
Notification::fake();
$request = Request::create(fake()->url, 'GET', [
'email' => 'nobody@example.com',
'password' => fake()->password,
]);
event(new Lockout($request));
Notification::assertNothingSent();
});
Now let's apply the corresponding changes to the SendLockoutNotification listener:
public function handle(Lockout $event): void
{
$user = User::whereEmail($event->request->email)->first();
if (! $user) {
return;
}
Notification::send($user, new MultipleFailedLoginAttempts);
}
Greenlight.
The Notification
The MultipleFailedLoginAttempts notification is straightforward:
public function toMail(object $notifiable): MailMessage
{
/** @var User $notifiable */
$token = Password::createToken($notifiable);
$payload = [
'token' => $token,
'email' => $notifiable->email,
];
return (new MailMessage)
->greeting("Hi {$notifiable->name},")
->line('We noticed several unsuccessful attempts to access your account on ' . config('app.name') . '.')
->line('If this was you, please double-check your login credentials.')
->line(
'If it wasn\'t, we recommend contacting our support team. You can reach us at [' .
config('app.emails.security') .
'](mailto:' .
config('app.emails.security') . ').'
)
->line('You should also consider changing your password.')
->action(
'Reset password',
route('password.reset', $payload)
);
}
To preview the notification during development:
Route::get('/notification', function () {
$user = User::find(1);
return (new MultipleFailedLoginAttempts)
->toMail($user);
});
Greenlight.
Afterword
That's it — users will now be notified when multiple failed login attempts occur on their account. Combined with the suspicious login detection from the previous article, you've got solid coverage for SOC 2 alerting requirements.