Building Web Apps with Laravel Module 3 – Requests, routing and middleware
Middleware and rate limiting
Write Laravel 13 middleware that runs before and after your code, attach it by alias, group or attribute, limit requests per key, and add safe response headers.
What you will learn
- Write a middleware that refuses a request before the action runs and changes the response after it
- Add a middleware globally, to a group, to a route and with the Middleware attribute, and choose the right place
- Define a named rate limiter and read the status and headers of a request it refuses
- Choose a limiter key that does not lock out every user behind one shared address
- Add security headers with a middleware and prove them with a test that fails without it
Before you start
What you’ll learn · Before you start
What you will learn
- Write a middleware that refuses a request before the action runs and changes the response after it
- Add a middleware globally, to a group, to a route and with the Middleware attribute, and choose the right place
- Define a named rate limiter and read the status and headers of a request it refuses
- Choose a limiter key that does not lock out every user behind one shared address
- Add security headers with a middleware and prove them with a test that fails without it
Before you start
On this page
Pathshala’s sign-in page sends a one-time code by SMS, and every SMS costs money. A limit of 3 codes a minute for each phone number is generous for a parent and cheap for the school, but the same limit of 3 for each IP address would stop an entire school wifi network after one family had logged in. Both rules are middleware, and the difference between them is a single word in the code, the key.
This lesson writes a middleware with a before step and an after step, shows the four places it can be attached, runs a rate limiter against seven requests, and ends by adding the response headers that Laravel leaves out. Every example runs on Laravel 13.34 with the clock frozen, so the numbers you read are the numbers it printed.
A middleware can stop a request or change its response
The first lesson of this module showed middleware as layers around the router. A middleware is a class with one method, handle, which receives the request and a closure named $next. Anything the method does before $next($request) happens on the way in, and anything after happens on the way out. Returning a response without calling $next stops the request. Here is a middleware for Pathshala’s office desk:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
// Before: refuses a request outside opening hours. After: stamps the response that comes back.
class EnsureCentreIsOpen
{
// The two arguments come from the text after the colon: 'centre.open:9,17' gives $opens = '9', $closes = '17'.
public function handle(Request $request, Closure $next, string $opens, string $closes): Response
{
$hour = now()->hour;
if ($hour < (int) $opens || $hour >= (int) $closes) {
// Returning here, without calling $next, stops the request: no controller runs.
return response("The centre is open from {$opens}:00 to {$closes}:00.", 403);
}
$response = $next($request);
$response->headers->set('X-Open-Hours', "{$opens}-{$closes}");
return $response;
}
} It takes two extra arguments, $opens and $closes. They arrive from the text after a colon in the route, so centre.open:9,17 calls handle($request, $next, '9', '17'), always as strings. Outside those hours it returns a 403 itself. Inside them it calls $next, takes the response that came back, and sets a header on it before returning it. Step through two requests, one at 08:30 and one at 10:30:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A request at 08:30. The browser asks for /office. The route carries centre.open:9,17, so the request reaches the middleware first, with 9 and 17 as its two extra arguments.
- The hour is outside the range. The middleware reads the clock, finds hour 8, and returns a 403 response itself. It never calls $next, so the route action is not reached.
- The refusal goes back. The 403 travels back to the browser with the message about opening hours. The action did no work.
- A request at 10:30. Two hours later the same request arrives. The middleware finds hour 10, inside the range, and decides to let it through.
- The action runs. The middleware calls $next($request). That runs the route action, which returns the text office desk.
- A header is added on the way out. The response is back in the middleware, which now sets the X-Open-Hours header on it and returns it. Work after $next sees the finished response.
- The browser gets 200. The browser receives the page with the header 9-17.
The route, with its alias, is one line of routes/web.php, and the alias is one line of bootstrap/app.php, where $middleware->alias(['centre.open' => EnsureCentreIsOpen::class]) gives the class its short name. middleware alias: A short name for a middleware class, registered in bootstrap/app.php, so that a route can write ->middleware('throttle:60,1') with the class's arguments after a colon. exist so that a route reads like a sentence and carries its arguments with it. The framework registers its own the same way: auth, throttle, signed and can are aliases.
Key idea
Work before $next sees the request and can refuse it. Work after $next sees the response and can change it. When a middleware returns a response itself and skips $next, nothing deeper runs.
Four places to attach a middleware, and what each one misses
Where you attach a middleware decides which requests meet it. The same alias runs the same code in all of these places:
<?php
use App\Http\Controllers\HallController;
use Illuminate\Support\Facades\Route;
// Middleware on a route, by alias with arguments.
Route::get('/office', fn () => 'office desk')->middleware('centre.open:9,17');
// Middleware on a controller, by attribute.
Route::get('/hall', [HallController::class, 'show']);
Route::get('/hall/notice', [HallController::class, 'notice']); /office carries the alias in its route. /hall gets it from a Middleware attribute on the controller, #[Middleware('centre.open:9,17', except: ['notice'])], which only and except narrow to chosen actions. Ask the router what it finally holds, and run the three paths at both hours:
from session import run
# The same three paths at 08:30 and at 10:30. The route and the controller use the same alias.
run(
"""
php ask.php 8 /office /hall /hall/notice
php ask.php 10 /office /hall /hall/notice
php artisan route:list --except-vendor -v
""",
app=True,
overlay=("mw-probe",),
) Output
$ php ask.php 8 /office /hall /hall/notice
8:30 GET /office 403 The centre is open from 9:00 to 17:00.
8:30 GET /hall 403 The centre is open from 9:00 to 17:00.
8:30 GET /hall/notice 200 the notice board is always on
$ php ask.php 10 /office /hall /hall/notice
10:30 GET /office 200 office desk [X-Open-Hours: 9-17]
10:30 GET /hall 200 exam hall is ready [X-Open-Hours: 9-17]
10:30 GET /hall/notice 200 the notice board is always on
$ php artisan route:list --except-vendor -v
GET|HEAD hall .................................... HallController@show
⇂ web
⇂ centre.open:9,17
GET|HEAD hall/notice ........................... HallController@notice
⇂ web
GET|HEAD office ..................................... routes/web.php:7
⇂ web
⇂ centre.open:9,17
Showing [3] routes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hours_session.py
At 08:30 the office and the hall answer 403 and the notice board answers 200, because the attribute excluded it. At 10:30 all three answer 200, and the two protected ones carry X-Open-Hours: 9-17. In the route list, -v shows centre.open:9,17 under the hall, with no mention of the attribute, because the router has already merged it. The list also shows web under each route, the group every route of routes/web.php belongs to.
The other places are the global list, with $middleware->append(...) or prepend(...), and a group, with $middleware->web(append: [...]) or appendToGroup. Their differences decide where each one belongs:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Place | Requests it covers | A URL no route handles | Taking it off one route | Arguments |
|---|---|---|---|---|
| Global How it is written: $middleware->append(Class::class) in bootstrap/app.php | Good every request the application receives | Good runs, so a 404 is covered too | Poor withoutMiddleware cannot remove a global one | Mixed one class, with arguments only through a text after a colon |
| A group How it is written: $middleware->web(append: [...]) or appendToGroup | Mixed only routes loaded into the group, such as web | Poor does not run, because no route was chosen | Good withoutMiddleware on a route or a route group | Mixed the same text after a colon |
| One route or route group How it is written: ->middleware('alias:args') in a routes file | Good exactly the routes you name, or a group of them | Poor does not run, because no route was chosen | Good withoutMiddleware on a route inside a group | Good alias:9,17 gives the arguments in place |
| Controller attribute How it is written: #[Middleware('alias')] on a class or method | Good the actions of one controller, with only and except | Poor does not run, because no route was chosen | Mixed a WithoutMiddleware attribute or except on the class | Good alias:9,17 works the same way |
How the answers decide
- “Must it also run on a URL that no route handles, such as an error page?” Yes, every response needs it: rules out A group, One route or route group and Controller attribute. A route is chosen after the global layer, so only a global middleware sees a request that matches no route.
- “Does it belong to every route, or only to some of them?” Some routes: rules out Global. A global middleware cannot be switched off for one route, so a rule for some routes belongs in a group, a route or a controller.
- “Does it belong to every route, or only to some of them?” Every route and “Must it also run on a URL that no route handles, such as an error page?” No, only matched routes: adds a caveat to A group. A group covers only the routes loaded into it, so an API route in another group is missed.
One more control exists for the rare case where the order of two middleware matters and you do not control it. A middleware priority: A fixed relative order for chosen middleware classes, set with the priority method, used when two of them must run in a given order whatever order a route lists them in. list, set with $middleware->priority([...]), fixes the order of the classes it names. The URL defaults of the routing lesson are the usual reason, a middleware that sets defaults must run before SubstituteBindings.
Excluding a global middleware from one route
withoutMiddleware() removes route middleware only. A class in the global list cannot be removed from a single route, so a rule that should skip some routes must not start out global.
Checkpoint
Check: Before and after $next, and stopping a request · Where to attach a middleware
A named limiter counts requests for each key
Rate limiting is a middleware too. The throttle alias runs ThrottleRequests, which counts the requests that share a key and refuses the ones over the limit. You define a limiter by name in the boot method of a service provider, with the RateLimiter facade, and you attach it by that name. The probe project defines one for the fee lookup page:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// A named limiter: three requests a minute for each IP address.
RateLimiter::for('fee-lookup', fn (Request $request) => Limit::perMinute(3)->by($request->ip()));
}
} Limit::perMinute(3)->by($request->ip()) says three requests in a rate limiter: A named rule, defined with RateLimiter::for, that allows a number of requests in a time window for each key and makes the throttle middleware answer 429 after that. window of a minute for each rate-limit key: The value a limiter counts requests under, such as an IP address, a user id or a phone number. Requests with the same key share one counter., and here the key is the caller’s address. The route says ->middleware('throttle:fee-lookup'). Send four requests from one address, one from another, one from a JSON client, and one after the minute has passed. The harness freezes the clock so that the numbers repeat:
from session import run
# Four requests from one address, one from another, a JSON client, and one request after the minute has passed.
run(
"""
export APP_DEBUG=false
php send_requests.php
""",
app=True,
overlay=("throttle-probe",),
) Output
$ export APP_DEBUG=false $ php send_requests.php 203.0.113.9, request 1 200 remaining 2 203.0.113.9, request 2 200 remaining 1 203.0.113.9, request 3 200 remaining 0 203.0.113.9, request 4 429 remaining 0 retry-after 60 198.51.100.4, request 1 200 remaining 2 203.0.113.9, as JSON 429 remaining 0 retry-after 60 JSON keys: message message: Too Many Attempts. 203.0.113.9, 61 s later 200 remaining 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 throttle_session.py
The first three requests answer 200, with X-RateLimit-Remaining falling from 2 to 0. The fourth answers 429 with Retry-After: 60, which tells a client how many seconds to wait. The other address starts a fresh count, because its key is different. The request 61 seconds later answers 200 again with remaining 2, since the old counter has expired.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The key is built. The limiter function returns Limit::perMinute(3)->by($request->ip()). The key combines the limiter name and the address, so each address has a counter of its own.
- The counter is read. The middleware reads how many requests this key has made in the current minute. For the first request of 203.0.113.9 the answer is zero.
- The count is compared. Zero has not reached the limit of three, so the request is allowed. The framework compares the counter with the limit before it counts this hit.
- The first three pass. The middleware adds one to the counter and calls $next. The response carries X-RateLimit-Remaining of 2, then 1, then 0.
- The fourth is stopped. By the fourth request the counter is three, which is the limit. The middleware throws, and Laravel answers 429 with a Retry-After header of 60 seconds. The controller never runs.
- The window moves on. After the minute passes the counter expires. In the run, a request 61 seconds later answered 200 with remaining 2, as the first one had.
A JSON client gets a JSON body, and what the body carries depends on debug mode. In the run above, with debug off, the body has the single key message. With debug on, it carries four more:
from session import run
# The same requests with debug mode on: look at what the 429 answer for a JSON client carries.
run(
"""
export APP_DEBUG=true
php send_requests.php
""",
app=True,
overlay=("throttle-probe",),
) Output
$ export APP_DEBUG=true $ php send_requests.php 203.0.113.9, request 1 200 remaining 2 203.0.113.9, request 2 200 remaining 1 203.0.113.9, request 3 200 remaining 0 203.0.113.9, request 4 429 remaining 0 retry-after 60 198.51.100.4, request 1 200 remaining 2 203.0.113.9, as JSON 429 remaining 0 retry-after 60 JSON keys: message, exception, file, line, trace message: Too Many Attempts. 203.0.113.9, 61 s later 200 remaining 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 throttle_debug_session.py
The JSON line now lists the keys message, exception, file, line, trace. A 429 body that contains a stack trace tells an attacker which file and line produced it. Keep debug mode off on any server that other people can reach.
Checkpoint
Check: Named limiters and the 429 answer
The key decides who gets locked out
Limiting by address is the easy choice, and it fails exactly where a school lives. Many families share one address, behind the school wifi or a mobile carrier’s gateway. Pathshala’s OTP route should limit each phone number tightly, so one number cannot flood a parent with SMS, and also limit each address more loosely, so a script cannot walk through thousands of numbers. A limiter function may return a list of limits, and a request must pass all of them:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Two limits at once: 3 a minute for each phone number from each address, and 20 a minute for each address.
RateLimiter::for('otp', fn (Request $request) => [
Limit::perMinute(3)->by($request->input('phone').'|'.$request->ip()),
Limit::perMinute(20)->by($request->ip()),
]);
// The same address limit, written the careless way: one bucket for everyone who shares the address.
RateLimiter::for('otp-by-ip', fn (Request $request) => Limit::perMinute(3)->by($request->ip()));
}
} The otp limiter keys three a minute on the phone number joined to the address, and twenty a minute on the address alone. The otp-by-ip limiter is the careless version, three a minute for the address. Three families sit behind one address and post to both routes:
from session import run
# Three families behind one address, against a limiter keyed by phone number and one keyed by address.
run(
"""
php otp_requests.php
""",
app=True,
overlay=("otp-probe",),
) Output
$ php otp_requests.php limiter keyed by phone and address, three families on one address 98000 00001: 200 200 200 429 98000 00002: 200 200 200 429 98000 00003: 200 200 200 429 limiter keyed by address only, the same three families 98000 00001: 200 200 98000 00002: 200 429 98000 00003: 429 429
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 otp_session.py
With the key of phone and address, each family gets its own three codes and its own fourth refusal, and the address limit of twenty is never reached. With the address alone, the first family uses both of its attempts, the second gets one and a refusal, and the third is refused at once, although it has not asked for a code before.
Behind a load balancer there is a second trap, in which the address is always the balancer’s, and every user shares it whatever the key was meant to be. The requests lesson shows the setting that makes $request->ip() return the visitor’s address. For routes where a user is signed in, the user’s id is the better key, as in the documentation’s $request->user()?->id ?: $request->ip().
A key made of input is a key the caller chooses
When the key includes a value from the request, such as a phone number, a caller can change it to start a new count. That is why the phone limit is paired with an address limit, which a caller cannot change from one machine.
Checkpoint
Check: Choosing the limiter key
A middleware adds the security headers Laravel leaves out
A default Laravel response carries almost none of the headers that tell a browser how to treat a page. The OWASP HTTP Headers Cheat Sheet lists them. Pathshala’s first set is small: X-Content-Type-Options: nosniff so the browser does not guess a file type, Referrer-Policy: strict-origin-when-cross-origin so full URLs do not leak to other sites, X-Frame-Options: SAMEORIGIN so other sites cannot put the page in a frame, and Cache-Control: no-store, private for pages that show a child’s data. Each is a security header: A response header that tells the browser how to treat a page, for example X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Laravel sets almost none by default.. The cheat sheet adds that a Content-Security-Policy frame-ancestors directive obsoletes X-Frame-Options for browsers that support it, so the older header is a cheap first step rather than the last word. Begin with the test, which states what every response must carry, whether it is a page, a missing URL or the health check:
<?php
namespace Tests\Feature;
use Tests\TestCase;
class SecurityHeadersTest extends TestCase
{
// The headers every response should carry, whatever the route: a page, a 404 and the health check.
public function test_every_response_carries_the_safe_defaults(): void
{
foreach (['/', '/no-such-page', '/up'] as $path) {
$this->get($path)
->assertHeader('X-Content-Type-Options', 'nosniff')
->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
->assertHeader('X-Frame-Options', 'SAMEORIGIN');
}
}
public function test_pages_with_personal_data_are_not_stored(): void
{
$this->get('/')->assertHeader('Cache-Control', 'no-store, private');
}
} Run it on a new project:
from session import run
# The test describes the headers; a new project sends none of them, so it fails.
run(
"""
!php artisan test --filter=SecurityHeadersTest
""",
app=True,
overlay=("headers-test",),
) Output
$ php artisan test --filter=SecurityHeadersTest
FAIL Tests\Feature\SecurityHeadersTest
⨯ every response carries the safe defaults …s
⨯ pages with personal data are not stored …s
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\SecurityHeadersTest > every response carries the sa…
Header [X-Content-Type-Options] not present on response.
Failed asserting that false is true.
at tests/Feature/SecurityHeadersTest.php:14
10▕ public function test_every_response_carries_the_safe_defaults(): void
11▕ {
12▕ foreach (['/', '/no-such-page', '/up'] as $path) {
13▕ $this->get($path)
➜ 14▕ ->assertHeader('X-Content-Type-Options', 'nosniff')
15▕ ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
16▕ ->assertHeader('X-Frame-Options', 'SAMEORIGIN');
17▕ }
18▕ }
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\SecurityHeadersTest > pages with personal data are…
Header [Cache-Control] was found, but value [no-cache, private] does not match [no-store, private].
Failed asserting that two strings are equal.
-'no-store, private'
+'no-cache, private'
at tests/Feature/SecurityHeadersTest.php:22
18▕ }
19▕
20▕ public function test_pages_with_personal_data_are_not_stored(): void
21▕ {
➜ 22▕ $this->get('/')->assertHeader('Cache-Control', 'no-store, private');
23▕ }
24▕ }
25▕
Tests: 2 failed (3 assertions)
Duration: …s
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 headers_before_session.py
Both tests fail. The first says Header [X-Content-Type-Options] not present on response, which confirms that nothing sets it. The second shows the one header Laravel does send, Cache-Control: no-cache, private, and that it differs from what the test wants. A test that fails on the weak default is the proof that it checks the right thing. Now add the middleware, registered globally so that a 404 and the health check are covered:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
// Adds a small set of response headers to every response. They are written after $next, on the response it returned.
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
// Laravel marks a page "no-cache, private". These pages show a child's name, so ask browsers not to keep them.
$response->headers->set('Cache-Control', 'no-store, private');
return $response;
}
} It sets the headers after $next, on the returned response, and the fix is one line in bootstrap/app.php: $middleware->append(SecurityHeaders::class). Run the same test again:
from session import run
# The same test after the middleware is added and registered globally.
run(
"""
php artisan test --filter=SecurityHeadersTest
""",
app=True,
overlay=("headers-test", "headers-fix"),
) Output
$ php artisan test --filter=SecurityHeadersTest PASS Tests\Feature\SecurityHeadersTest ✓ every response carries the safe defaults …s ✓ pages with personal data are not stored …s Tests: 2 passed (20 assertions) Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 headers_after_session.py
Both pass, with 20 assertions behind them. One part of this middleware is blunt: no-store goes on every response, including public pages that could be cached. Pathshala would move that single line into a separate middleware attached to the routes that show a child’s data, and leave the three others global.
Checkpoint
Check: Security headers from a middleware
Practise
Add the pieces to your own Pathshala project, and check each one with a request:
- Write a middleware that reads the
X-Request-Sourceheader and returns 400 when it is missing, and register it with an alias. Attach it to one route, then to a controller with#[Middleware], and confirm both withphp artisan route:list -v. - Define a limiter named
receiptsthat allows 5 requests a minute for each signed-in user and 2 for each guest address. Write a feature test that sends six requests as one user and asserts that the sixth answers 429, then travels a minute forward and asserts 200. - Add the security headers middleware and the test from this lesson, then move
Cache-Controlinto its own middleware and attach it only to the routes that show student data.
Then work through the self-check.
Practice that adapts to you
Practise this lesson
16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between work done before $next($request) and work done after it in a middleware?
Work before $next happens on the way in. The middleware sees only the request, so it can read or change the request, or refuse it by returning a response of its own, in which case $next is never called and nothing deeper runs. Work after $next happens on the way out. The middleware now holds the response that the controller or a deeper layer returned, so it can add headers, change the status or log the result. Use the first for checks such as authentication, hours and rate limits, and the second for anything that decorates a response, such as security headers. A header set before $next is lost, because no response exists yet, and a check made after it is too late to stop the controller from running.
Three longer questions come from this lesson:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
On the morning of a mock test, parents report that the sign-in code never arrives, and the log shows a stream of 429 answers from the OTP route. Everyone was on the school's wifi. Find the cause in the limiter, redesign it, and say how you would test the new design.
-
Fresher to mid
A security scan reports that the home page has an X-Frame-Options header but the 404 page and the health check do not. The header comes from a middleware your team wrote. Explain how that can happen and how you would fix it and keep it fixed.
-
Mid to senior
A maintenance-hours middleware was added to the global list and now it blocks a payment webhook that must always be reachable. Someone suggests withoutMiddleware on the webhook route. Say whether that works, and propose a design that does.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What can a middleware do before calling $next($request), and what after?
Before $next it sees only the request, so it can check, change or refuse it by returning its own response, and then nothing deeper runs. After $next it holds the response, so it can add headers, change the status or log the result.
-
Where can a middleware be attached, and which place also sees requests no route handles?
Globally, to a middleware group, to a route or route group by alias, or to a controller with the Middleware attribute. Only a global middleware also runs for a URL that matches no route.
-
What does a limiter defined with RateLimiter::for do once a key passes its limit?
It counts the requests of each key and lets them through up to the limit. Past it, every request gets status 429, and its Retry-After header says how many seconds to wait.
-
Why is an IP address alone a poor key for limiting OTP requests at a coaching centre?
Many families can share one address, so a limit on the address alone locks all of them out together. A key of phone number and address, with a looser limit on the address, protects the SMS budget without that cost.
-
Which security headers does a new Laravel app send, and how do you add them?
Almost none by default. A global middleware that sets them after $next, plus a test that fails when one is missing, makes the headers a checked part of the application.
Key takeaways
- A middleware sees the request before
$nextand the response after it, and returning a response without calling$nextstops the request at that layer. - A middleware is attached globally, to a group, to a route or a route group by alias, or to a controller with the
Middlewareattribute, and only a global middleware also sees a request that no route handles. - A named limiter defined with
RateLimiter::forcounts requests for each key, lets the requests up to the limit through, and answers 429 with aRetry-Afterheader after that. - The key decides who is locked out, so a limit keyed by address alone blocks every family behind one shared address, and a phone-and-address key plus a looser address limit protects the SMS budget without that cost.
- Laravel sends almost no security headers by default, so a global middleware that sets them after
$nextand a test that fails without it make the headers a checked part of the application.
Terms in this lesson
- middleware alias
- A short name for a middleware class, registered in bootstrap/app.php, so that a route can write ->middleware('throttle:60,1') with the class's arguments after a colon.
- middleware priority
- A fixed relative order for chosen middleware classes, set with the priority method, used when two of them must run in a given order whatever order a route lists them in.
- rate limiter
- A named rule, defined with RateLimiter::for, that allows a number of requests in a time window for each key and makes the throttle middleware answer 429 after that.
- rate-limit key
- The value a limiter counts requests under, such as an IP address, a user id or a phone number. Requests with the same key share one counter.
- security header
- A response header that tells the browser how to treat a page, for example X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Laravel sets almost none by default.
References
- Middleware (Laravel (laravel.com))
- Rate limiting (Laravel (laravel.com))
- Routing, rate limiting (Laravel (laravel.com))
- Controllers, middleware attributes (Laravel (laravel.com))
- HTTP Headers Cheat Sheet (OWASP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress