Middleware and rate limiting — putting cross-cutting defenses in one place
Authentication and authorization are in place. What remains are decisions you want made before a request reaches its individual handler.
Recall the path traced in chapter 2: a request passes through middleware before reaching the router. This chapter inserts our own processing into that layer. The rate limiting that protects the login API from brute forcing goes in here too.
Where middleware sits
We have been using auth:sanctum, which is middleware too. It sits where it can touch both the request and the response.
It runs on the way in and on the way out. You can inspect a request and reject it, or add a header to the response.
There are three scopes at which to apply it.
| Scope | How to write it | Example |
|---|---|---|
| Every request | append / prepend in bootstrap/app.php | Attaching a request ID |
| Per group | Added to the api or web group | CORS |
| Per route | ->middleware('...') | auth:sanctum |
Writing your own middleware
We build the endpoint that receives notifications from the payment service: POST /api/v1/webhooks/payment from the chapter 1 endpoint list.
A webhook is a mechanism where an external service calls your server directly. There is no authentication token to pass, so a signature confirms it is genuine instead.
The sender computes a signature from the request body and a shared secret and puts it in a header. The receiver performs the same computation and checks that they match.
php artisan make:middleware VerifyPaymentWebhookSignature
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyPaymentWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$signature = $request->header('X-Payment-Signature');
if (! is_string($signature)) {
abort(401);
}
$expected = hash_hmac(
'sha256',
$request->getContent(),
config('services.payment.webhook_secret'),
);
if (! hash_equals($expected, $signature)) {
abort(401);
}
return $next($request);
}
}
Three things here are deliberate.
It takes the raw body with $request->getContent(), not $request->all(). The signature was computed over the exact bytes the sender sent, so reconstructing them from the parsed array does not match. Change the order of JSON keys or the whitespace and you have a different byte sequence.
It compares with hash_equals(). === works, but string comparison scans from the start and stops at the first difference. Processing time varies slightly with how many characters match, so measuring the time while trying one character at a time can reveal the signature (a timing attack). hash_equals() always takes the same time for equal-length inputs.
It reads the secret from config(). As chapter 2 settled, env() is not called here. Add the setting to config/services.php.
return [
// ... existing settings ...
'payment' => [
'key' => env('PAYMENT_API_KEY'),
'webhook_secret' => env('PAYMENT_WEBHOOK_SECRET'),
],
];
Forget this addition and config() returns null. hash_hmac() happily computes with an empty key, so nothing errors and signature verification is effectively disabled. Because nothing errors, you find out when either "it always returns 401 for some reason" or "a forged webhook got through."
Registering it
Give the middleware a name so routes can reference it1.
use App\Http\Middleware\VerifyPaymentWebhookSignature;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'webhook.payment' => VerifyPaymentWebhookSignature::class,
]);
})
Route::post('/webhooks/payment', [PaymentWebhookController::class, 'handle'])
->middleware('webhook.payment');
For something you want on every request, use append.
$middleware->append(AddRequestId::class);
You can also add to the whole api group.
$middleware->api(append: [
EnsureJsonResponse::class,
]);
Writing the receiver
Create the PaymentWebhookController the route references.
php artisan make:controller PaymentWebhookController
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PaymentWebhookController extends Controller
{
public function handle(Request $request)
{
// The middleware already verified the signature; by the time we are here, the sender is genuine
if ($request->input('event') !== 'payment.succeeded') {
return response()->json(['status' => 'ignored']);
}
// Recording the payment is assembled in chapter 17
return response()->json(['status' => 'accepted']);
}
}
Event types you do not handle also return 200. A payment service sends several kinds of notification to one endpoint. Return 4xx or 5xx for an unknown type and the sender treats it as a failure and keeps retrying. The retry mechanism covered later in this chapter would keep running unintentionally. Received but did nothing is communicated with a 200.
Recording the payment itself is not written here. It needs a design that assumes the same notification arrives twice, so chapter 17 handles it.
Rate limiting
The login API from the previous chapter has no limit on attempts. Passwords can be tried endlessly.
Laravel's rate limiting is configured in two steps: define a named limit, then apply it to a route.
The definition goes in AppServiceProvider's boot method2.
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->ip());
});
}
Add throttle to the login route written in chapter 11.
Route::post('/auth/login', [AuthController::class, 'login'])
->middleware('throttle:login'); // ← added
Now a sixth login attempt from the same IP address within a minute gets 429 Too Many Requests.
What to count by
The value passed to by() becomes the unit of counting. The design here is what matters.
Counting by IP address alone leaves a way around it. Spread the attack across IPs and the limit never trips. Five attempts each from 100 IPs against one account is 500 attempts.
Count by email address as well2.
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(500),
Limit::perMinute(5)->by($request->input('email')),
];
});
Return an array and every limit applies at once.
- The first is the overall ceiling: 500 per minute across the endpoint
- The second is per account: 5 attempts per minute against the same email address
Spread across IPs or not, attempts against the targeted account stop at five.
Counting by authenticated user
For a general API rate limit, use the user ID when logged in and the IP otherwise.
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
The same user across several devices is counted together.
Prefix the key when units overlap
Take care when applying several limits keyed on the same value2.
RateLimiter::for('uploads', function (Request $request) {
return [
Limit::perMinute(10)->by('minute:'.$request->user()->id),
Limit::perDay(1000)->by('day:'.$request->user()->id),
];
});
Use $request->user()->id for both without a prefix and the two limits share one counter. The per-minute and per-day limits get mixed and neither behaves as intended.
The 429 response
When a limit trips, Laravel returns a 429 automatically. The headers carry the following.
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
Retry-After: 47
Retry-After is how many seconds to wait. The client should read it and wait; retrying immediately only extends the limit.
Document what these three headers mean in your API docs. Without that, a client receiving a 429 has no idea what to do.
CORS
If a browser-based frontend lives on another domain, you need CORS configuration.
The CORS configuration file is not in config/ initially. cors.php and view.php are configuration files that are not published by default3. If the default behavior suits you, there is nothing to do.
To narrow the allowed origins, first extract the configuration file3.
php artisan config:publish cors
Edit the generated config/cors.php.
'allowed_origins' => ['https://shop.example.com'],
Do not ship to production with '*'. It leaves the API callable from any site via a browser.
Note that CORS is a browser mechanism, so it is no substitute for server-side authorization. It has no effect on requests from curl or a mobile app. The authorization added in the previous chapter is the real defense; CORS only restricts calls from another site in a browser.
Tests
Confirm the rate limit works.
test('login attempts are limited after five', function () {
User::factory()->create(['email' => 'taro.test@example.com']);
foreach (range(1, 5) as $i) {
$this->postJson('/api/v1/auth/login', [
'email' => 'taro.test@example.com',
'password' => 'wrong-password',
])->assertStatus(422);
}
$this->postJson('/api/v1/auth/login', [
'email' => 'taro.test@example.com',
'password' => 'wrong-password',
])->assertStatus(429);
});
The first five give the 422 authentication error, and the sixth gives 429.
Rate limit counters are stored in the cache. As chapter 10 mentioned, RefreshDatabase restores only the database. Counts accumulated in a previous test survive into the next.
Clear the cache around this test.
beforeEach(function () {
Cache::clear();
});
The cache itself is covered in chapter 18. For now, just take on board that rate limiting uses the cache.
Test the webhook signature verification too. The failure case alone is not enough.
test('a webhook with a valid signature is accepted', function () {
config(['services.payment.webhook_secret' => 'test-secret']);
$payload = ['event' => 'payment.succeeded', 'order_id' => 1];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'test-secret');
$this->call(
'POST',
'/api/v1/webhooks/payment',
content: $body,
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_PAYMENT_SIGNATURE' => $signature,
],
)->assertStatus(200);
});
test('a webhook with an invalid signature returns 401', function () {
config(['services.payment.webhook_secret' => 'test-secret']);
$this->postJson('/api/v1/webhooks/payment', ['event' => 'payment.succeeded'], [
'X-Payment-Signature' => 'invalid',
])->assertStatus(401);
});
Write only the failure-case test and it goes green even with no controller at all, because the middleware stops it with a 401 and nothing beyond is reached. Only by writing "a valid signature gets through" do you confirm the whole path works.
The happy-path test uses $this->call(). postJson() converts an array to JSON before sending, so there is no guarantee it matches the byte sequence you computed the signature over. In a test that checks the correspondence between body and signature, decide the bytes you send yourself.
What matters in production
The api group does not include throttle
Laravel's api middleware group contains no rate limiting by default. Do not assume "it is Laravel, so something must be limiting it."
Only the routes you explicitly mark get limited. List your public endpoints and decide for each whether it needs a limit.
Ordinary rate limiting is not enough against ID enumeration
Chapters 4 and 12 mentioned the attack of walking IDs to collect information.
Apply a limit like Limit::perMinute(60) and enumeration proceeds 60 at a time: 60 a minute, 3,600 an hour. Set it to a value that does not disrupt normal use and it does not stop enumeration either.
Rate limiting is a tool for stopping "a lot in a short time"; it does nothing about "a little at a time over a long time." The countermeasures for ID enumeration are outside this chapter.
- Return 404 — covered in chapter 12. Choose between 403 and 404 so existence does not leak
- Use unguessable IDs — a ULID or UUID cannot be walked in order
- Monitor the error rate — a run of 404s from the same user is abnormal. Covered by log design in chapter 21
Rate limiting supplements those three; it is not a countermeasure on its own.
A webhook that gets a 5xx will be resent
Most payment services resend after a delay when a webhook returns 5xx. That is correct design, so a transient failure does not drop an event.
The receiving side has to behave correctly when the same event arrives twice. Run "record the payment" twice and it gets recorded twice.
Idempotency, the countermeasure, is covered in chapters 14 and 17. Do not forget this premise when writing a webhook receiver.
Design so that middleware order does not matter
Attach several pieces of middleware and execution order can change the result. Middleware placed after auth:sanctum can use $request->user(); placed before, it gets null.
If you write processing that depends on order, state the dependency. Even a one-line comment on the middleware class stops a later reader from reordering it.
Decide rate limit values while operating
You cannot know the right value from the start. Too strict and legitimate users get rejected; too loose and it is pointless.
Log how often 429s occur so you can adjust later. Chapter 21 covers log design, but rate limiting is a high-priority thing to monitor. Logs or support tickets are the only way you learn that legitimate users are being rejected.
Summary
- Middleware runs on the way in and on the way out. It applies at three scopes: global, group, and route
- Verify a webhook signature against the raw body. Reconstructing it from the parsed array does not match
- Compare signatures with
hash_equals().===leaves room for a timing attack - Define rate limits in
AppServiceProvider::boot()and apply them withthrottle:<name> - Counting by IP alone can be evaded by spreading out. Count login attempts per account too
- Prefix the key to separate counters when applying several limits on the same value
- The
apigroup does not include throttle. Only the routes you mark are protected - Webhooks are resent on 5xx. Build on the premise that the same event arrives twice
What to read next
The layers protecting the API are now in place. From the next chapter we tackle concurrent requests and slow processing. The next chapter, Transactions and concurrency — closing double-allocated stock, solves the "another request slips in between the check and the update" problem we have deferred since chapter 5, using transactions and row locks.
Exercises
The following webhook verification never matches the signature. Explain the cause
public function handle(Request $request, Closure $next): Response
{
$expected = hash_hmac(
'sha256',
json_encode($request->all()),
config('services.payment.webhook_secret'),
);
if ($expected !== $request->header('X-Payment-Signature')) {
abort(401);
}
return $next($request);
}
Sample answer
It computes the signature over json_encode($request->all()). That can easily differ from the bytes the sender signed.
$request->all() is the received JSON parsed into a PHP array. Turning it back into a string with json_encode() is not guaranteed to reproduce the original bytes.
Here are the concrete sources of divergence.
- Key order — parsing and regenerating does not necessarily preserve order
- Whitespace and newlines — if the sender sent formatted JSON, it differs from
json_encode()'s output - Number representation —
1.0becoming1, or exponent notation changing - Unicode escaping —
json_encode()escapes non-ASCII characters as\uXXXXby default
Any one of these makes the hash a completely different value. It is not "sometimes passes, sometimes fails" but fails almost always.
The correct call is $request->getContent(), using the exact bytes that arrived.
The !== comparison is worth fixing too. Use hash_equals() so no room is left for a timing attack.
How do you respond to "we limited it to 5 per minute per IP address, so login brute forcing is handled"?
Sample answer
It is not handled. The unit of the limit does not match the unit of the attack.
From an attacker's point of view, IP addresses are easy to change: cloud instances, proxies, botnets. With 100 IPs you get 500 attempts a minute. The limit is honoring "5 per IP," so the limit is working correctly. It just is not protecting what you wanted protected.
What you want to protect is "attempts against one account." So count in that unit.
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(500),
Limit::perMinute(5)->by($request->input('email')),
];
});
However spread out the IPs are, attempts against the targeted account stop at five a minute.
That configuration has a side effect of its own, though. An attacker can deliberately lock a specific account out. Keep sending five failures a minute against someone's email address and they cannot log in (a denial of service).
There is no complete answer, but you can mitigate. Reset the counter on a successful login, email the account owner past a certain number of failures, tighten the limit only for attempts from unusual times or regions. How far you go is a trade-off between the value of what you protect and implementation cost.
The important thing is not to stop at "we added a limit," but to articulate what unit you are counting in and what you are protecting. With the wrong unit, the limit works and protects nothing.