Time to pay off the introduction. There's a dependency baked into authorization itself that none of these three frameworks can design around: you can't decide what someone is permitted to do until you've established who they are, so identity has to be settled first, regardless of which framework is doing the settling. Get that backwards, on whichever stack you happen to be building with, and you've reconstructed the exact bug that let Marcus's return through.
The Express prototype is where it really happened. Here's close to the literal diff from the rushed patch that caused it:
// BEFORE the patch — correct
app.post("/returns/:returnId/approve", authenticate, requireManager, approveReturn);
// AFTER — someone "simplified" the route line during an unrelated refactor
// and re-added the manager check as a separate app.use call underneath it
app.post("/returns/:returnId/approve", authenticate, approveReturn);
// ...
// forty lines later, in a different part of the file:
app.use(requireManager); // added here — but this route already matched and responded above
Express walks its middleware and routes top to bottom, in registration order, for every single request. Once app.post("/returns/:returnId/approve", ...) matches and approveReturn sends a response, that request is done — nothing registered further down the file, requireManager included, ever gets a look at it. requireManager wasn't broken. It wasn't buggy. It was in the file, correctly written, and it never ran, because the request that needed it had already finished being handled thirty-nine lines above.
The ASP.NET Core prototype has the identical trap, with different syntax:
// The order that lets Marcus's boots through
app.MapControllers();
app.UseAuthentication();
app.UseAuthorization();
MapControllers() registers the terminal, endpoint-executing middleware — the thing that actually calls into approveReturn. Put it before UseAuthentication/UseAuthorization in the pipeline, and by the time a request reaches the controller action, authorization has never run at all — it's registered, it exists, and it's simply downstream of where the request already got answered. The fix is only a reordering, and it's exactly the one ASP.NET Core's own pipeline-ordering guidance calls out by name:
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // now nothing reaches a controller action unauthenticated
And the Django prototype hides a quieter version of the same thing, which is exactly why process_view exists rather than everyone just checking request.path in __call__:
# An earlier version of RequireManagerMiddleware, before it used process_view
class RequireManagerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/returns/approve/" and not request.user.is_shift_lead:
return HttpResponseForbidden()
return self.get_response(request)
That worked fine, right up until the team moved Counter's routes under an /api/ prefix and renamed the URL to include the return's ID directly — /api/returns/<id>/approve/. The hardcoded string "/returns/approve/" never matched a real request again, and the check went silent without a single test failing, because nothing about the refactor touched RequireManagerMiddleware.py itself. The process_view version from two chapters back doesn't have this failure mode at all — it's keyed to the view function itself, resolved by Django's own router, so it keeps working through any number of URL reshuffles that never touch what the view is.
Three frameworks, three completely different mechanisms, and the same lesson under all of them: a correctly-written authorization check that never actually sits on the path a request takes is functionally identical to no check at all — and none of the three frameworks will tell you that's what happened. No error, no warning, nothing but a $1,140 return that shouldn't have gone through.
A second ordering habit catches nearly as many people, and it points in the opposite direction from the first: anything reworking a response on the way out has to sit above whatever's producing that response, not below it — a direct consequence of the wrap-then-unwrap shape from the opening chapter. Compression has to touch the finished body, so it needs to be declared early enough in the list to enclose everything underneath it, approveReturn included — close to the front, not buried further down:
MIDDLEWARE = [
"django.middleware.gzip.GZipMiddleware", # wraps everything below — compresses the final response
"myapp.middleware.ApprovalLogMiddleware",
"myapp.middleware.RequireManagerMiddleware",
]
Worth flagging, since this specific example invites it: compressing a response isn't free of its own risk. If a compressed response ever carries something secret — a CSRF token, a session identifier reflected back in the body — alongside anything an attacker can influence, like a query parameter, the compressed size itself can leak information about the secret, one guessed byte at a time. This is the shape of the BREACH-style attack, and it's specifically why Django's own docs carry a warning about
GZipMiddlewarerather than presenting it as a purely mechanical, risk-free win. ForCounter, the practical fix is narrow: never compress a response that both echoes attacker-controllable input and carries a secret in the same payload — which its plain JSON return records don't, but it's worth checking before reaching for compression on anything that does.