Let's Connect

A locked padlock on a dark keyboard, representing access control on a web application

Every public Laravel app needs laravel rate limiting on login, password reset, OTP, and any endpoint that sends email or SMS. Skip it and you are one script away from credential stuffing, a flooded inbox, or a surprise Twilio bill. The pieces are built in: a throttle middleware for blunt per-route limits, named RateLimiter limiters for anything that needs a smart key, and a Redis store so the counters are shared across every app server. The part people get wrong is the key, and getting the key wrong is how you let an attacker lock out your real users.

What is the quickest way to throttle a route?

The throttle middleware is the blunt instrument, and it is fine for most read endpoints. Attach throttle:<max>,<minutes> and Laravel keys the counter by authenticated user id, falling back to client IP for guests.

routes/web.php
// 60 requests per minute, keyed by user id or IP
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/dashboard', DashboardController::class);
});

// A named rate limiter (defined in a provider) applied by name
Route::post('/login', [LoginController::class, 'store'])
    ->middleware('throttle:login');

On Laravel 11 and 12 the api route group already ships with throttle:api applied, so your /api/* routes are not wide open out of the box. That default is generous (60/min) and IP-keyed, which is the right floor but the wrong ceiling for sensitive actions. Anything touching auth or token issuance deserves its own named limiter. I cover where those token endpoints live in Next.js + Laravel: building a decoupled architecture, which leans on Sanctum for the auth layer.

How do I key a limiter so attackers cannot lock out real users?

This is the whole game. If you throttle the login route purely by email, I can lock any user out of their own account just by submitting their email a few times, a denial-of-service handed to me for free. If you throttle purely by IP, a botnet rotating through thousands of IPs walks straight past it. The answer is a composite key: IP plus email. Define a named limiter in a service provider's boot method.

app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('login', function (Request $request) {
        $key = strtolower((string) $request->input('email')) . '|' . $request->ip();

        return Limit::perMinute(5)->by($key)->response(function (Request $request, array $headers) {
            return response()->json(
                ['message' => 'Too many login attempts. Try again shortly.'],
                429,
                $headers
            );
        });
    });

    // OTP and password reset get a much smaller numeric keyspace,
    // so they need stricter, separate limits.
    RateLimiter::for('otp', fn (Request $r) => Limit::perMinute(3)->by($r->ip()));
}

Note the $headers passed into the response closure: Laravel populates X-RateLimit-* and Retry-After for you, and forwarding them keeps clients informed even on your custom 429 body. Keying by email plus IP means a single attacker IP cannot grind a victim's account, and a single victim email cannot be weaponised to lock that account from elsewhere. For authenticated endpoints, key by the user id instead ($request->user()->id) so one tenant's traffic never eats into another's budget. Per-user keys are not a nicety: without them, one noisy client throttles everyone behind the same NAT.

Lines of code on a dark editor screen, representing a Laravel rate limiter definition
A named limiter keyed by email plus IP is the difference between blocking an attacker and locking out your own users.

Do I need to write login throttling myself?

No, and you probably should not. If you use Laravel Fortify or Breeze, login throttling is already there. Fortify's LoginRequest::ensureIsNotRateLimited calls RateLimiter::tooManyAttempts, fires event(new Lockout($request)), and throws a validation error carrying the seconds remaining from RateLimiter::availableIn. You get the correct behaviour for free, including the lockout event you can hook for alerting.

If you hand-roll authentication, replicate that contract exactly. Remember that tooManyAttempts only reads the counter, so you increment separately on failure. Check before authenticating, increment on failure, clear on success, and return a real 429 with a Retry-After header so clients (and your own SPA) know when to back off.

app/Http/Controllers/Auth/LoginController.php
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;

public function store(Request $request)
{
    $request->validate(['email' => 'required|email', 'password' => 'required']);

    $key = strtolower($request->input('email')) . '|' . $request->ip();

    if (RateLimiter::tooManyAttempts($key, $maxAttempts = 5)) {
        event(new Lockout($request));
        $seconds = RateLimiter::availableIn($key);

        return response()->json(
            ['message' => "Too many attempts. Retry in {$seconds}s."],
            429,
            ['Retry-After' => $seconds]
        );
    }

    if (! Auth::attempt($request->only('email', 'password'))) {
        RateLimiter::increment($key, decaySeconds: 60); // count this failure
        return back()->withErrors(['email' => 'Invalid credentials.']);
    }

    RateLimiter::clear($key); // reset the counter on success
    $request->session()->regenerate();

    return redirect()->intended('/dashboard');
}

For a one-off custom endpoint where you do not want the manual increment-then-check dance, RateLimiter::attempt($key, $maxAttempts, $callback) wraps both in a single call: it runs the callback only if the limit has not been hit and increments the counter for you, returning false when throttled. It is the cleanest option for things like a contact form or a resend-OTP action.

Why does the limiter store have to be Redis in production?

By default the limiter uses your cache store, and if that is the array or file driver, your counters live on a single box. Put two app servers behind a load balancer with a per-server in-memory limiter and the attacker effectively gets 5 attempts times N servers, and round-robin routing means they rarely hit the same counter twice. A shared Redis store fixes this: every node reads and writes the same counter, so 5 means 5 no matter where the request lands. Point your cache at Redis, then pin the limiter to it explicitly so a cache flush never wipes your active throttle counters. I go deeper on running Redis itself in Redis caching strategies that actually reduce database load.

config/cache.php
// Make the default cache Redis...
'default' => env('CACHE_STORE', 'redis'),

// ...then pin the rate limiter to its own store so a cache:clear
// or cache flush never wipes active throttle counters.
'limiter' => 'redis',

The matching .env, plus the one bootstrap tweak that maps the throttle middleware to the Redis-optimised implementation:

bootstrap/app.php
// .env
//   CACHE_STORE=redis
//   REDIS_CLIENT=phpredis
//   REDIS_HOST=127.0.0.1
//   REDIS_PORT=6379

// Use the Redis-backed throttle middleware (ThrottleRequestsWithRedis)
// instead of the default cache-driven one.
->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleWithRedis();
});

What does a layered defense actually look like?

Rate limiting is the floor, not the whole building. Stack defenses so that getting past one still leaves an attacker facing the next. Here is the order I reach for, cheapest first:

  • Progressive backoff: tighten the window as failures climb (5/min, then a 15-minute cooldown) instead of one flat limit.
  • A CAPTCHA after N failures, invisible to legitimate users on their first try, a wall to automation.
  • Soft account lock plus an email to the user on repeated failures, so a real owner is warned and can act.
  • Separate, far stricter limits for password reset and OTP: a six-digit code is a keyspace of one million, brute-forceable in minutes without a tight cap.
  • Alert on the Lockout event: a spike across many accounts is credential stuffing in progress, not one forgetful user.

All of this belongs in a broader hardening pass; rate limiting is one line item on the Laravel production security checklist I keep, not the entire list. For the exact mechanics of the throttle middleware and named limiters, the canonical reference is the Laravel rate limiting documentation.

Rate limiting keyed only by email is not protection. It is a denial-of-service feature you shipped on purpose.Md Raihan Hasan

Get three things right and you have closed the most common door attackers walk through: key sensitive limiters by IP plus identifier so you block attackers without locking out users, return an honest 429 with Retry-After so clients behave, and run the limiter on Redis so a load balancer cannot multiply your limits behind your back. None of it is exotic; it ships in the framework. The reason brute-force still works against Laravel apps in 2026 is not missing features. It is teams that left the defaults on a sensitive endpoint and never tested what happens when someone hammers it.