A Laravel login route behind a load balancer sees one IP address for every visitor. Every request arrives from 10.0.3.12, the balancer's private address, so the rate limiter you added last quarter treats your entire audience as one client. Either nobody gets throttled, or everybody does after the first sixty requests of the minute. Both outcomes look like protection in a code review and fail in production.
This guide is a Laravel-specific implementation for Laravel 11+ and 13.x syntax. It covers named route limiters, client IP resolution behind proxies, a cache store that all replicas share, tests for 429 responses and forged X-Forwarded-For headers, and a rollback plan. It is written for developers and platform engineers who run Laravel behind a CDN, load balancer or reverse proxy and want a safe first layer of bot controls in the application.
One limit up front: rate limiting is a control layer. It is not bot identity verification, and it only acts on requests that reach your origin.
What Laravel middleware can and cannot do against bots (version scope: Laravel 11+ and 13.x)
Laravel's throttle middleware counts requests per key inside a time window and returns 429 when the count passes a limit. That's the whole mechanism. It works well for the thing it was built for: capping how fast one identifier can hit one route.
It doesn't tell you who is on the other end. A scraper rotating through residential addresses stays under any per-IP limit you set, because each address makes a handful of requests. A real customer on a shared office network can trip the same limit that a single noisy script would. The limiter counts keys. It has no view of the browser, the connection or the intent behind the request.
It also acts late. Throttling runs inside your application, after the CDN, the load balancer and the PHP process have already accepted the request. Every blocked bot still costs you a bootstrap of the framework and a cache read. Edge policy is the place to stop volume before it reaches PHP.
Version scope matters because the syntax moved. This guide uses the Laravel 11+ application structure, where middleware and proxy trust can be configured in bootstrap/app.php with ->withMiddleware(), rather than in app/Http/Kernel.php. Laravel 13.x keeps that structure. If you're on Laravel 10 or earlier, the concepts hold but the file locations differ.
Here's my position on scope: use app-level limits to protect expensive or abusable routes (login, password reset, search, export, checkout steps), and treat them as a safety net. Don't call them bot detection in a security review. A reviewer who reads "bot protection: throttle:60,1" should be corrected before the audit, not after the incident.
Define named route limiters with RateLimiter::for and attach them with throttle
Named limiters beat inline throttle:60,1 strings because they carry a name you can grep, test and change in one place. Define them in the boot method of App\Providers\AppServiceProvider:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip()),
Limit::perMinute(30)->by($request->ip()),
];
});
RateLimiter::for('search', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by('user:' . $request->user()->id)
: Limit::perMinute(20)->by('ip:' . $request->ip());
});
}
Returning an array stacks limits. The login limiter above allows five attempts per email and address pair, and thirty per address overall. A script guessing passwords for one account hits the first cap quickly. A script spraying one password across many accounts from one address hits the second.
Attach a limiter to a route with the throttle middleware and the limiter's name:
Route::post('/login', [LoginController::class, 'store'])
->middleware('throttle:login');
Route::get('/search', SearchController::class)
->middleware('throttle:search');
You can customize the rejection with ->response(function (Request $request, array $headers) { ... }) on the Limit, which is useful when an API client expects a JSON body instead of an HTML error page. Keep the $headers argument in the response. It carries Retry-After and X-RateLimit-* values that well-behaved clients use to back off.
Start with two or three routes, not the whole application. A global limiter on every route punishes asset requests, health checks and webhooks, and it's the fastest way to generate a support ticket.
Choose keys: authenticated user versus anonymous IP, and why the key decides who gets blocked
The key passed to by() is the policy. Everything else is arithmetic.
Key on the authenticated user ID and a bot with a stolen session is capped no matter how many addresses it uses, while a legitimate user behind a corporate NAT is unaffected by their coworkers. Key on IP and the opposite happens: one office of two hundred people shares a budget, and a botnet with two thousand addresses gets two thousand budgets.
The search limiter above shows the usual split. Signed-in users get a generous per-user budget. Anonymous traffic gets a tighter per-IP budget, because IP is all you have. That asymmetry is deliberate. It pushes friction onto the unidentified requests and away from accounts you can already tie to a person.
Three rules for choosing keys:
- Prefix the key type. Use
user:42andip:203.0.113.9, not bare values. Without prefixes a user ID and an IP string can collide in the same cache namespace, and debugging a collision at 2 a.m. is a bad use of anyone's night. - Combine identifiers on credential routes. Login and password reset should key on the submitted email plus the IP, so an attacker can't lock a victim out of their own account from a single address without also being slowed down themselves.
- Never key on a header you haven't validated.
User-Agentis whatever the client says it is. A key built from it hands the attacker the controls.
Decide what failure you can tolerate. Keying on IP risks blocking shared networks. Keying on user risks leaving anonymous routes open. If an anonymous route is the abused one (signup, public search, a lead form), key on IP, set a budget a shared office can live with, and accept that a determined scraper will route around it. For the form case specifically, form bot protection covers reducing fake leads without blocking buyers.
Back the limiter with a shared cache so replicas count together
Laravel's rate limiter stores its counters in the cache. Which cache store you point it at decides whether your limit is real.
On a single server the file or array store appears to work. Behind a load balancer with four replicas, each replica keeps its own counter. A limit of 60 per minute becomes 240, and because the balancer spreads requests unevenly, a client can send 59 to each of four servers and never see a 429. The array store is worse: it resets on every request in PHP-FPM, so it never counts anything outside tests.
Use Redis, Memcached or the database store so all replicas share one set of counters. Redis is the usual choice because increments are atomic and fast. Set it in .env:
CACHE_STORE=redis
Laravel 11 renamed the config variable from CACHE_DRIVER to CACHE_STORE. If you upgraded from an older version and the old name is still in your environment files, the new one may not be set, and you'll silently fall back to the default store. Check both.
If you want the limiter on a different store than the rest of your cache, set the limiter key in config/cache.php:
'limiter' => 'redis',
That lets you keep application cache on one store while rate-limit counters live on a dedicated Redis instance with its own eviction policy. Don't let an allkeys-lru eviction setting on a shared Redis evict your counters under memory pressure. An evicted counter is a reset to zero, and your limit quietly disappears when traffic is highest.
There's a Laravel-specific variant, throttleWithRedis(), which swaps in a Redis-backed throttle middleware with slightly leaner behavior. Add it in bootstrap/app.php inside ->withMiddleware(function (Middleware $middleware) { $middleware->throttleWithRedis(); }) when Redis is your store and you want it. It's optional. The default middleware works against a Redis cache store without it.
Configure trusted proxies in bootstrap/app.php without trusting every proxy
Everything above depends on $request->ip() returning the real client. Behind a proxy it won't, unless you tell Laravel which proxies to believe.
When a request passes through a CDN or load balancer, the proxy adds X-Forwarded-For with the client address it saw. Laravel ignores that header unless the direct peer (the machine that opened the TCP connection to PHP) is on the trusted proxy list. That's the safety feature. Without it, any client could send X-Forwarded-For: 1.2.3.4 and pick its own identity.
In Laravel 11+ and 13.x you configure this in bootstrap/app.php:
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: ['10.0.0.0/8', '172.16.0.0/12'],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO,
);
})
List the actual addresses or CIDR ranges of your load balancer, ingress or CDN egress, not '*'. The wildcard tells Laravel to trust the forwarding headers from any peer. If your origin is reachable directly (a forgotten public IP, a staging hostname, a misconfigured security group), anyone can then forge their address and walk past every per-IP limit.
The tradeoff is real. CDN egress ranges change, and a hardcoded list goes stale. Two workable patterns: pin to your load balancer's private subnet and have the CDN reach the origin only through it, or load the CDN's published ranges from config and refresh them on deploy. Either beats '*'.
If you chain proxies, remember that X-Forwarded-For accumulates addresses left to right. Laravel walks from the right, skipping trusted proxies, and stops at the first untrusted address. That's why an accurate trusted list matters: leave out one hop and Laravel reports that hop as the client. The same real-IP problem exists on other stacks, and the Nginx bot protection guide covers it from the web server side.
Test it: 429 responses, Retry-After, and forged X-Forwarded-For headers
A limiter you haven't tested is a hypothesis. Write feature tests for three behaviors: the limit trips, the rejection is well-formed, and header forgery doesn't change identity.
First, the 429 itself:
public function test_login_is_throttled_after_five_attempts(): void
{
for ($i = 0; $i < 5; $i++) {
$this->post('/login', ['email' => 'a@example.com', 'password' => 'wrong']);
}
$response = $this->post('/login', ['email' => 'a@example.com', 'password' => 'wrong']);
$response->assertStatus(429);
$response->assertHeader('Retry-After');
}
The Retry-After assertion matters. Clients and monitoring use it to back off, and a custom ->response() callback that drops the headers breaks that contract without any visible error.
Second, clear state between tests. Counters persist in the cache, so a throttled address in one test leaks into the next. Call RateLimiter::clear('...') for the key, or flush the cache store in setUp. Use the array cache store in the test environment for isolation, and run a separate integration check against Redis, because the array store hides multi-replica problems.
Third, the forgery test. Send a request from an untrusted peer with a spoofed header and confirm Laravel ignores it:
public function test_forged_forwarded_for_does_not_change_the_key(): void
{
for ($i = 0; $i < 20; $i++) {
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.7'])
->withHeaders(['X-Forwarded-For' => '203.0.113.' . $i])
->get('/search');
}
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.7'])
->withHeaders(['X-Forwarded-For' => '203.0.113.250'])
->get('/search');
$response->assertStatus(429);
}
Here 198.51.100.7 isn't in the trusted list, so the rotating X-Forwarded-For values must have no effect and the twenty-first request must still be throttled. If this test returns 200, your trusted proxies are too broad. Add the inverse test too: from a trusted peer address, a forwarded header should change $request->ip().
Finally, test the real path once. From outside your network, hit the staging URL through the actual CDN and load balancer, log $request->ip() and the raw X-Forwarded-For, and confirm the logged IP is your own address, not the balancer's.
Roll out and roll back safely, and where edge-level bot policy picks up
Ship limits in observation mode first. Before enforcing a 429, log what would have been blocked. A thin wrapper that checks RateLimiter::tooManyAttempts($key, $max) and writes a log line instead of aborting gives you a week of data on who would have been throttled, including legitimate users behind shared addresses you hadn't predicted.
Then roll out one route at a time, lowest risk first. Search and public content routes before login. Login before checkout. Watch two numbers per route: the 429 rate and the completion rate of the action the route protects. A 429 spike with flat completions means you're catching bots. A 429 spike with falling completions means you're catching customers. Bot false positive testing describes how to verify visitors before blocking.
Make rollback a one-line change, not a deploy. Put the budget in config or an environment variable:
Limit::perMinute((int) config('limits.search_anon', 20))->by('ip:' . $request->ip())
To disable a limiter during an incident, raise the number to something very large via the environment and reload. Keep the limiter name attached to the route so you can restore the real value without touching route files. Document which routes carry which named limiter in the repository so the on-call engineer isn't reading source at 3 a.m.
Know where this layer ends. By the time a request hits Laravel, it has crossed your CDN, your balancer and your PHP workers. App middleware can't separate a fake crawler from a real one, and it can't see the browser or connection evidence that distinguishes automation from a person. That's edge work. Centinel Analytica tells real visitors from scrapers and applies allow, challenge or block policy through your existing CDN or edge, which keeps your Laravel limiters as a backstop rather than the first and only line. On a typical news site, 84% of requests claiming to be GPTBot were fake (Centinel Analytica research), and a per-IP counter inside PHP won't catch that.
Next steps: link to API rate limiting budgets, form bot protection and a Centinel site audit
Work through these in order. Each one is a small, reversible change.
- Set trusted proxies first. Replace any
'*'with explicit ranges inbootstrap/app.php, then run the forgery test. Every other control depends on a correct client IP. - Move the limiter to a shared store. Confirm
CACHE_STORE=redis(or another shared store) in every environment that runs more than one replica. - Name and attach limiters to three routes. Pick the most abused ones. Key authenticated traffic on user ID and anonymous traffic on IP.
- Add the three tests. 429 plus
Retry-After, state cleanup between tests, and a forgedX-Forwarded-Forcase. - Log before you block. Run a week in observation mode, then enforce one route at a time.
For the forms side of the problem, read the guide on form bot protection. For the edge side, the AWS and Akamai guides show how CDN-level policy complements app-level limits.
Conclusion
Treat your Laravel limiters as the second layer, not the first. Fix trusted proxies, move counters to a shared cache, and test the forged-header case this week, because a limiter keyed on the wrong IP protects nothing. Then look at what reaches PHP in the first place. Centinel applies allow, challenge or block policy through the CDN you already have, so Laravel only sees the traffic you decided to let through.
Frequently asked questions
How do I rate limit login attempts in Laravel 11?
Define a named limiter with RateLimiter::for('login', ...) in AppServiceProvider::boot, return Limit::perMinute(5)->by(email and IP), then attach it with ->middleware('throttle:login'). Stack a second per-IP limit in an array to slow password spraying. Test for a 429 and a Retry-After header.
Why does Laravel rate limiting not work behind a load balancer?
Usually one of two causes. Either $request->ip() returns the balancer's address because trusted proxies are not set in bootstrap/app.php, so all users share one key, or the cache store is per-server (file or array), so each replica keeps separate counters. Fix both: trust specific proxy ranges and use Redis.
Should I trust all proxies in Laravel with '*'?
Avoid it unless your origin is only reachable through the proxy. The wildcard tells Laravel to trust proxy headers from any peer connecting directly to the application, meaning a direct-origin caller can potentially spoof the client IP used by your rate limiters. List your load balancer or CDN ranges explicitly and run a forged-header test.
Is Laravel throttle middleware enough to stop scrapers?
No. Throttle counts requests per key. It cannot verify whether a client is a real browser or a bot, and it acts only after requests reach your origin. Rotating addresses stay under per-IP limits. Centinel Analytica tells real visitors from scrapers and applies policy through your existing CDN or edge.
How do I test a Laravel rate limiter?
Write a feature test that sends requests up to the limit, then asserts a 429 status and a Retry-After header on the next one. Clear limiter state between tests, and add a test that rotates X-Forwarded-For from an untrusted REMOTE_ADDR to confirm the key does not change.
