Lindsay Edwards

Middleware order is correctness

On this page

Middleware looks like a list you can shuffle. It is not. On one API, the order of two lines decided whether the rate limiter protected users or punished them, and I only really understood it when I imagined getting it wrong.

Here is the order that mattered. Auth ran first. The rate limiter ran second.

Why that order was load-bearing#

Auth does one thing that the limiter depends on: it works out who the caller is and sets the user id on the request context. Only after that runs does anyone downstream know whose request this is.

The rate limiter keyed on user-id-or-fallback-to-IP. If the context has a user id, limit by user. If it does not, fall back to the client IP. Read that in the right order and it is exactly what you want. Authenticated traffic is limited per user, so your budget is yours and nobody else can spend it. Only anonymous traffic, which has no user id to key on, falls back to IP.

Now swap the two lines. The limiter runs first, before auth has set anything, so the context has no user id yet. Every request, authenticated or not, hits the fallback and gets limited by IP.

That sounds harmless until you picture real traffic. Put an office, a school, or a whole mobile carrier behind one NAT, and hundreds of genuine, separately authenticated users share a single public IP. Key the limiter on IP and they all land in the same bucket. One busy user, and everyone behind that router starts eating 429s for traffic that was never theirs. A 429 there is the app turning a paying user away and telling them to come back later, so the wrong order quietly converts your rate limiter from a shield into a way to lock a whole office out.

Same two middlewares. Same config. The only thing that changed was which one ran first, and it changed who the limiter thinks you are.

The identity your rate limiter keys on is decided entirely by what ran before it. Order is not decoration. Order is the answer to “who is this?”

The ingress that has no session#

There was a second half to this, and it is the part people miss when they hear “just put auth first”.

Webhook routes could not live inside the authenticated group at all. Inbound callbacks from providers arrive with no session, no logged-in user, nothing for the auth middleware to find. Run them through the same auth-first pipeline and they get rejected before they reach the handler, because they cannot satisfy a check that assumes a human logged in.

So the webhook routes were mounted on their own branch, outside the authed group entirely. They are not unauthenticated in the careless sense. They authenticate by a different mechanism: an HMAC signature on the payload, verified against a shared secret, which proves the request really came from the provider without needing a session at all.

That is the general shape. Session-based auth and signature-based auth are both auth, but they belong on different paths, because the thing they verify is different. One proves a user; the other proves an origin.

Order is a correctness property#

If you take one thing from this, take that middleware order is a correctness property, not a style choice. The pipeline is a sequence of things writing to and reading from a shared context, and each one can only use what an earlier one already put there. Your limiter cannot key on a user id that auth has not set yet.

So put auth ahead of anything that wants per-user behaviour. And when a request genuinely cannot carry a session, do not force it through the session path. Give it its own branch and its own way of proving it is legitimate.

Get the order right and it disappears into the happy path. Get it wrong and you will find out from the one office all sharing one router, wondering why the app keeps rate-limiting people who barely touched it.

Keep reading