Request Lifecycle: How Web Frameworks Handle a Request · Spring, the Browser Side, and Putting It Together
Choosing the Right Layer for Cross-Cutting Concerns
Why one forgotten permission check exposed every customer's invoices, and how to place authentication, authorisation, validation, logging and transactions in the right layer of any framework.
Scenario
Example app: a multi-tenant API (the same story happens in Laravel, Django, Express or Spring) checks permissions inside each controller with a line like if not user.can_view(invoice): return 403. Forty endpoints have the check. A new export endpoint, added in a hurry, doesn't.
What you see in production
- A security researcher downloads other tenants' invoices through the new export endpoint.
- The code review approved it: the check is easy to miss because nothing in the framework requires it.
The naive code
def export_invoices(request):
invoices = Invoice.objects.filter(id__in=request.GET.getlist("ids")) # no tenant or permission check
return csv_response(invoices)
Reproduce it
The simulation registers routes with and without a central policy layer that denies by default unless a route declares its permission.
routes = {
"GET /invoices": {"permission": "invoices.view", "controller_checks": True},
"GET /invoices/{id}": {"permission": "invoices.view", "controller_checks": True},
"POST /invoices": {"permission": "invoices.create", "controller_checks": True},
"GET /invoices/export": {"permission": None, "controller_checks": False}, # added in a hurry
}
user_permissions = set() # an attacker from another tenant with no invoice permissions
def per_controller(route):
return "403" if routes[route]["controller_checks"] else "200 (LEAK)"
def default_deny(route):
needed = routes[route]["permission"]
if needed is None:
return "403 (route declares no permission: denied by default)"
return "200" if needed in user_permissions else "403"
for route in routes:
print(f"{route:<22} per-controller: {per_controller(route):<10} pipeline default-deny: {default_deny(route)}")
Output
GET /invoices per-controller: 403 pipeline default-deny: 403
GET /invoices/{id} per-controller: 403 pipeline default-deny: 403
POST /invoices per-controller: 403 pipeline default-deny: 403
GET /invoices/export per-controller: 200 (LEAK) pipeline default-deny: 403 (route declares no permission: denied by default)Root cause
Authorisation was opt-in, written by hand in each controller. Every framework has a layer that runs for every matched route; the team didn't use it, so one omission became a data leak.
The fix
Put each cross-cutting concern in the layer built for it, and make security default-deny:
| Concern | Laravel | Symfony | Django | Flask | FastAPI | Express | NestJS | Next.js | Spring |
|---|---|---|---|---|---|---|---|---|---|
| Request IDs, CORS, headers | middleware | kernel.request/response listeners |
middleware | before/after_request |
middleware | middleware | middleware | proxy | filter |
| Authentication | auth middleware |
security firewall | AuthenticationMiddleware |
extension + before_request |
dependency | middleware | guard | proxy (optimistic) + data layer | Spring Security filters |
| Authorisation | policies, can middleware |
voters, #[IsGranted] |
permission mixins/decorators | decorators | dependency | route middleware | guard + service checks | server components, route handlers | method security |
| Validation | Form Requests | #[MapRequestPayload] + Validator |
forms/serializers | schemas | Pydantic models | schema middleware | pipes | schema in actions/handlers | @Valid |
| Error formatting | exception handler | kernel.exception |
middleware / handlers | error handlers | exception handlers | 4-arg error middleware | exception filters | error.tsx, handlers |
@ControllerAdvice |
| Transactions | service / DB::transaction |
Doctrine in services | atomic |
session per request | dependency with yield |
service | interceptor or service | data layer | @Transactional services |
Then:
- Configure the framework so routes must declare authorisation, or are denied (deny-by-default middleware, a global guard, a router-level dependency, Spring Security's
anyRequest().authenticated()). - Keep resource-level checks (this invoice belongs to this tenant) in the service or query layer, applied to every query (tenant scopes).
- Add an automated test that lists all routes and fails if any lacks an authorisation rule.
Trade-offs
- Global layers run for everything; exclude health checks and public assets explicitly rather than opting routes in.
- Some checks need loaded data (ownership), so they can't run before the controller; pair a coarse pipeline check with a fine-grained service check.
Detect it in production
- Route inventory tests and periodic security reviews of new endpoints.
- Log authorisation decisions with the route and rule, and alert on routes that never record a decision.
Key points
- Every framework has a pipeline layer for each concern; use it instead of copy-pasted controller code.
- Make authorisation default-deny and verify it with a route-inventory test.
- Combine coarse checks in the pipeline with resource-level checks near the data.
Practice questions
Where should authentication and authorisation live in a web framework?
Answer
Authentication in the pipeline layer that runs for every request (middleware, firewall, guard, filter, dependency). Coarse authorisation (roles, permissions) in route-level middleware, guards or dependencies with default-deny; resource-level authorisation (ownership, tenant) in services or query scopes near the data.
What does 'default-deny' mean for API routes, and how do you enforce it?
Answer
Routes are rejected unless they explicitly declare who may access them. Enforce it with a global guard/middleware/dependency that requires a declared rule, and a test that inspects the route table for routes without one.
Map the request-pipeline building blocks of three frameworks you know.
Answer
For example: Laravel middleware, Form Requests, policies and the exception handler; NestJS middleware, guards, interceptors, pipes and exception filters; Spring filters, HandlerInterceptors, @Valid, method security and @ControllerAdvice. The concepts match even when names differ.
Open in the app to mark lessons complete, take notes and track your progress.