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.

Create a free account to save this page, take notes and track your progress. Sign in

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.

Was this page helpful?