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.

Per-controller checks vs a default-deny pipeline layerTry it yourself
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.