Appearance
9.9.2 — Routing Deep: Routers, Params, Locals & Trust Proxy
An app works perfectly on a laptop. It goes behind a load balancer and three things break at once: the rate limiter starts blocking everybody or nobody, every log line records the same IP address, and users cannot stay logged in. Nothing in the code changed. One line of routing configuration was missing, and section 5 is about that line.
That is the shape of this page. Routing looks like the boring part of Express — match a path, call a function — and it holds several details that only ever hurt in production.
1. Routers: the unit of modularity
express.Router() creates a mini-application — its own middleware stack and route table — mountable at a path: ⚑What is Express Router? [EQ-149]
javascript
// routes/orders.js — a feature owns its pipeline AND its routes
const router = express.Router();
router.use(authenticate); // (1) applies to EVERY route below —
router.use(express.json({ limit: "50kb" })); // protection by construction (9.9.1)
router.get("/", listOrders);
router.post("/", validate(CreateOrder), createOrder);
router.get("/:orderId", getOrder);
export default router;
// app.js
app.use("/api/orders", ordersRouter); // (2) mounted — routes are RELATIVE:
// router.get("/") ⇒ GET /api/ordersThree consequences of that mounting are worth having straight.
The mount path is invisible from inside the router. A request for /api/orders/9142 reaches the router with req.url set to /9142 — the prefix has been stripped, which is exactly why the router's own routes can be written relative and mounted anywhere. When you need the full picture, req.baseUrl holds the prefix the router was mounted at and req.originalUrl holds the whole original path. This trio quietly confuses logging code: log req.url from inside a router and every entry looks like a top-level path.
Routers nest inside routers. ordersRouter.use("/:orderId/items", itemsRouter) gives order items their own file. There is one catch, and it is the most commonly forgotten option in Express: by default a child router cannot see the parent's parameters, so req.params.orderId is undefined inside the items router. Create it with express.Router({ mergeParams: true }) and the parent's params come through.
A router is itself a middleware. There is no special mounting mechanism; app.use(path, router) is the ordinary use you already know, and the router is just a function of (req, res, next). That is why routers compose freely, why you can mount one conditionally, and why you can test one in isolation by handing it to a test client without building the whole app.
All of which makes a router the natural boundary for a feature — cohesion at folder scale, in 9.1's terms. One router per resource, owning its own authentication, validation, rate limits, and error behaviour. The practical payoff shows up in code review: someone can verify how orders are protected by reading a single file, instead of tracing which of nine global middleware happen to apply to which paths.
2. Params vs query — the design distinction
javascript
app.get("/orders/:orderId/items/:itemId", (req, res) => {
req.params; // { orderId: "9142", itemId: "3" } ← from the PATH — identity
req.query; // { fields: "id,total", page: "2" } ← from the ?… — modifiers
});Both arrive as strings, always (3.6.7's coercion boundary — req.params.itemId is "3", and "3" + 1 === "31"): parse and validate at the edge ([9.9.3]'s schemas). The design rule behind the syntax: ⚑Difference between params and query? [EQ-152]
- Path params identify the resource — they're part of which thing you're addressing, they're required, and they belong in the URL's hierarchy (9.6.1's ownership nesting).
- Query params modify the representation — filtering, sorting, pagination, projections (9.6.2's grammar); they're optional, order-independent, and cacheable-visible (query strings participate in cache keys; path params define the resource identity).
The consequence that decides real API designs: /orders?id=9142 and /orders/9142 are not stylistic alternatives — the first says "a filtered collection," the second "this specific order," and only the second gives you per-resource caching, ETags (9.6.3), and clean 404 semantics. Query strings for identity is the tell of a Level-1 API (9.6.1).
Express 5 note: the router now uses path-to-regexp v8 — wildcards are named (/files/*splat), optional segments use {/:id} instead of :id?, and unnamed regex groups are gone. If you're reading Express 4 blogs against an Express 5 app, this is the syntax that will bite you.
3. Router.param(): the pre-loading hook
A route with :orderId almost always begins the same way: fetch the order, 404 if missing, check ownership. Written per handler, that's 9.1's duplicated knowledge in five places. router.param() runs a hook whenever a named param appears in a matched route: ⚑What is Router.param()? [EQ-944]
javascript
router.param("orderId", async (req, res, next, orderId) => { // 4th arg = the value
const parsed = OrderId.safeParse(orderId); // validate at the edge
if (!parsed.success) return next(new BadRequestError("invalid orderId"));
const order = await orders.find(parsed.data);
if (!order) return next(new NotFoundError("order", orderId)); // 404 once, not per route
if (!canAccess(req.user, order)) return next(new ForbiddenError());
req.order = order; // attach for handlers
next();
});
router.get("/:orderId", (req, res) => res.json(req.order)); // handlers get it FREE
router.patch("/:orderId", (req, res) => { /* req.order guaranteed loaded + authorized */ });
router.post("/:orderId/cancel", cancelOrder); // …every route belowTwo facts about when it runs. It fires once per request, even when the same parameter appears in a parent and a child router, so you do not pay for the database lookup twice. And it fires only when a route actually matched, so a request to a path that does not exist never triggers the fetch.
Now the honest cost, because this is a trade and not a free win. The handler for GET /:orderId reads req.order and there is nothing in that file saying where it came from. Someone new has to know to look at the router. That is hidden coupling in 9.1's sense, accepted deliberately in exchange for removing the same six lines from every route.
Keep it affordable: few param handlers, obvious names, and defined right next to the router they belong to, never imported from somewhere distant. In TypeScript, declare the attachment with module augmentation (3.7.6) so req.order has a real type. That turns the hidden input into something the editor can show you, which is most of what made it hidden.
4. Chaining, method semantics, and all
Route chaining groups a path's methods so the path string appears once — fewer typos, obvious grouping: ⚑What is route chaining? [EQ-156]
javascript
router.route("/:orderId")
.get(getOrder)
.patch(validate(PatchOrder), updateOrder)
.delete(requireAdmin, deleteOrder); // per-method middleware, inlinePer-route middleware is just extra arguments (router.post("/", rateLimit, validate, handler)) — they run in argument order, and any may short-circuit (9.9.1). app.all("/path", fn) matches every method (useful for path-wide guards); router.use differs from router.all in one way worth knowing: use matches path prefixes (/orders matches /orders/9142/items), all matches the exact path.
The method semantics themselves are 9.6.1's contract, and the two questions the bank asks: PUT vs PATCH — PUT replaces the whole representation and is idempotent; PATCH applies a partial change and is not idempotent by default (an increment patch applied twice differs). PUT vs POST — POST creates where the server assigns identity (POST /orders → 201 + Location); PUT writes to a client-chosen URI (PUT /orders/9142 creates-or-replaces at that exact address, idempotently). ⚑Difference between PUT and PATCH? [EQ-154]⚑Difference between PUT and POST? [EQ-155]
5. app.locals vs res.locals — and trust proxy
Two objects, two lifetimes, endlessly confused: ⚑What are app.locals and res.locals? [EQ-946]
app.locals— application-scoped, created once, shared by every request for the process's life: config, the app version, a template helper. Writing request data here is a cross-request data leak (user A's value served to user B) — the 9.4.6 global-state disease with a security consequence.res.locals— request-scoped, born and destroyed with the response: the authenticated user, the trace ID, per-request feature flags, view variables. This is where request context belongs (and it composes withAsyncLocalStorage— 3.8.7 — when the value must reach code that never seesres).
trust proxy is the one-line setting that decides whether your app believes X-Forwarded-* headers: ⚑What is trust proxy in Express? [EQ-947]
javascript
app.set("trust proxy", 1); // trust exactly ONE hop (your load balancer)
// then: req.ip is the CLIENT's IP (from X-Forwarded-For), not the proxy's;
// req.protocol is "https" when the LB terminated TLS;
// req.secure is true — so secure-cookie logic works (Part 8.4)To see why this matters, picture the actual network. A user's browser connects to your load balancer over HTTPS. The load balancer decrypts that, then opens its own plain HTTP connection to your Node process. So from your app's point of view, every request in production comes from one IP address — the load balancer's — over an insecure connection. The real client's address and the real protocol survive only as headers the proxy added: X-Forwarded-For and X-Forwarded-Proto.
Express refuses to believe those headers unless you tell it to, and that refusal is correct, because a header is just text that anybody can send.
With the setting missing, four things break, none of them loudly.
Rate limiting stops working. Every request appears to come from the same IP, so all your users share one bucket (9.7.5). Either the limit is high enough that nobody is ever limited, or one busy customer trips it and everyone else is blocked.
Your logs become useless for anything involving a user. Every line records the proxy's address, so you cannot answer "where did these requests come from" during an incident, and your audit trail says nothing.
Login stops working, in the most confusing possible way. Session cookies are usually set with secure: true, meaning the browser should only send them over HTTPS. Express thinks the connection is plain HTTP, so it declines to set the cookie at all. The login request returns 200, everything looks successful, and the user lands back on the login page with no error anywhere.
And any redirect-to-HTTPS middleware loops forever, because the app believes every request is insecure — including the ones that just came back from the redirect.
With the setting too permissive, you get the opposite problem. app.set("trust proxy", true) means "believe whatever X-Forwarded-For says". Since a client can send that header itself, anyone can now claim any IP address they like: bypass the rate limiter with a fresh fake IP per request, get someone else's address written into your audit log, or slip past an IP allow-list (Part 8.5).
The right value is a fact about your deployment. How many proxies actually sit in front of this process? One load balancer means 1. A CDN in front of a load balancer means 2. Express then takes the address that many hops from the end of the chain and ignores anything a client tried to prepend. If you know your proxies' addresses, you can list those subnets explicitly instead. Either way this is deployment configuration, not a constant — the same code deployed behind a different setup needs a different number, so read it from config rather than hard-coding true and hoping.
6. The expert lens
Routers are where structure and security turn out to be the same decision. Put the guard on the router and every route mounted there is protected, including routes that do not exist yet. Put the guard on each route and protection depends on every future developer remembering — and remembering is not a control, it is a hope with a good track record until the week someone is in a hurry.
This gives you a concrete review question for any Express codebase. Can a developer add a route that is not protected? If yes, count how many places that is possible; that number is your security debt, and it is the only measure of it that means anything.
Everything from the network is a string, and everything about the network can lie. Path parameters, query values, headers, bodies — all of it arrives as text chosen by whoever sent the request, including people who wish you harm. Parse and validate it at the edge ([9.9.3] covers the schemas), and treat headers like X-Forwarded-For, Host, and Referer as claims that you decide whether to believe.
Notice that nearly every "it works on my machine" Express incident is one of these trust decisions being absent. Wrong client IPs, session cookies that will not set, HTTPS redirect loops, rate limits that never fire — all of them appear only in production, for the same reason: your laptop has no proxy in front of it, so there is nothing there to lie.
Hidden context is a budget you can overspend. req.user, req.order, res.locals.traceId — each of these makes a handler shorter and makes the reader guess. A few of them are worth it. Twenty of them produce a codebase where you cannot read any handler without first discovering which five middleware ran before it.
Spend the budget on purpose: keep the set small, write it down, and make sure each attachment is created by middleware with an obvious name, so req.user traces back to authenticate and req.order to the orderId param handler. Then type them through declaration merging (3.7.6), so the editor lists what exists on req instead of leaving every reader to grep for it.
What the interviewer will push on
Routing questions in interviews are rarely about matching paths. They are about the production details that only show up after deployment.
"What is the difference between req.params and req.query?" The syntax answer is one sentence and does not impress anyone. The answer that does: path parameters say which thing you are talking about, query parameters say how you want it presented. Then give the consequence — /orders/9142 can be cached per resource, carry an ETag, and return a clean 404, while /orders?id=9142 is a collection request that happens to return one item and can do none of those things.
"Your rate limiter isn't working in production but works locally. Why?" They are checking whether you have deployed behind a proxy. Missing trust proxy means every request appears to come from the load balancer, so the entire internet shares one bucket. The follow-up is the interesting half: what happens if you set it to true? Now clients can forge X-Forwarded-For and bypass the limiter entirely. The right answer is the actual number of proxies in front of the app, and knowing that both errors exist is what separates a real answer from a memorised one.
"Users log in successfully but end up back on the login page. Walk me through it." A debugging question with several possible causes; the one worth reaching for first is the secure-cookie case — the app thinks the connection is HTTP, so the session cookie is never set. Naming trust proxy here, and explaining that the login request itself returned 200, shows you have chased a bug that produced no error.
"How do you stop every handler from repeating the same fetch-and-404 logic?" Router.param() is the Express-specific answer, and it is worth mentioning because most candidates have never used it. Balance it honestly: it removes duplication and adds an invisible input, so keep them few and typed. A candidate who names the downside unprompted reads as someone who has maintained code rather than only written it.
"Where do you keep the current user during a request?" They want res.locals or req, and they want to hear why not app.locals — because that object lives for the life of the process, so writing request data there serves one user's data to the next. That is not a style mistake, it is a data leak between customers, and it is a genuinely common one.
Volunteer this one, because nobody asks: mention mergeParams: true. Nested routers silently lose the parent's path parameters, so req.params.orderId is undefined inside an items router and the resulting bug looks like a database problem rather than a routing one. It is a small thing to know and it costs an afternoon to discover.
Next: 9.9.3 — the error architecture: the four-argument handler, errors from async code, telling expected failures apart from bugs, custom error classes, and validating every boundary.
Recall
- Router = mini-app (own stack + routes), mounted with
app.use(path, router): mount path stripped inside (req.urlrelative;baseUrl/originalUrlhold the rest), nests withmergeParams: true, is itself middleware. Feature-owned pipelines = protection by construction. - Params identify, query modifies: path params = required identity (per-resource caching, ETags, clean 404s); query = optional filter/sort/page/projection. Both always strings — parse at the edge.
/orders?id=9142vs/orders/9142is a design difference, not style. - Router.param() pre-loads and authorizes a named param once per request, only on matched routes — validate → fetch → 404 → authorize → attach (
req.order); DRY across every route using it, at the cost of implicitness (keep few, name clearly, type via declaration merging). - Chaining (
router.route(path).get().patch()) groups methods; per-route middleware are extra args;usematches prefixes,allmatches the exact path. PUT = whole + idempotent; PATCH = partial, not idempotent; POST = server-assigned identity, PUT = client-chosen URI. - app.locals lives as long as the process, so request data put there leaks to other users. res.locals lives for one request, so it is the place for the user, the traceId and view variables.
- trust proxy says how many proxy hops to believe.
- Left unset behind a load balancer,
req.ipis wrong, so every user shares one rate limit and the logs are wrong too.req.secureis also wrong, so secure cookies fail and redirects loop. - Set to
true, it lets anyone fakeX-Forwarded-For. Set it to the real number of hops.
- Left unset behind a load balancer,
Self-test: What do req.url, req.baseUrl, req.originalUrl hold inside a mounted router? State the params-vs-query design rule and its caching consequence. What does Router.param run, when, and how often? Name four production failures caused by a missing trust proxy. Which locals object leaks across users?
Quiz Bank
FoundationalWhat is an Express Router, and why is it the right unit of modularity?
A Router is a mini-application: its own middleware stack plus its own route table, mountable at a path (app.use("/api/orders", ordersRouter)). Underneath, a router is middleware. That is why it can be combined, nested (with mergeParams: true when a child needs the parent's parameters) and mounted only under certain conditions.
Inside a mounted router, paths are relative. Three values tell you where you are, and they confuse logging middleware until you know them:
req.urlhas the mount prefix removed.req.baseUrlholds that prefix.req.originalUrlholds the full path.
A router is the right unit for splitting up an app, because it lines up the code's structure with its security and validation rules. Put router.use(authenticate) and router.use(express.json({ limit })) at the top of the file, and every route in that file gets them automatically. A future developer cannot add an unprotected route there. A rule people had to remember becomes a property of the design (9.3.6). And a reviewer can check a feature's protection by reading one file (9.1).
FoundationalParams vs query: the mechanical difference, the design rule, and why it affects caching.
Mechanically: req.params comes from named path segments (/orders/:orderId → { orderId: "9142" }), req.query from the query string (?page=2&sort=-createdAt); both are always strings (3.6.7) and both are attacker-controlled — parse and validate at the boundary.
Design rule: path params identify the resource (required, hierarchical, part of the address — 9.6.1); query params modify the representation (optional filtering/sorting/pagination/projection — 9.6.2).
What it means for caching. A separate path is a separate thing a cache can hold. It has its own ETag (9.6.3), and a 404 that means something when it is missing. A query-string filter such as /orders?id=9142 is a filtered view of a collection instead. Caches treat the whole query string as part of the key. A 404 would be the wrong answer, because the honest answer is an empty list. And you can no longer clear the cache for one order on its own. Hence /orders/9142 for identity, /orders?status=paid for selection — and identity-in-query is the tell of an API stuck at Richardson Level 1.
AppliedShow Router.param() solving the repeated-lookup problem, and state its trade-off.
Every route under /:orderId needs the same preamble — parse the id, fetch, 404, authorize — which is 9.1's duplicated knowledge across five handlers (and the one place someone forgets the ownership check is a data leak). router.param("orderId", async (req, res, next, value) => {…}) runs that preamble once per request, only when a matched route contains that param: validate the value (safeParse), next(new BadRequestError()) on garbage, fetch, next(new NotFoundError()) when absent, next(new ForbiddenError()) when the caller can't access it, then attach req.order = order and next(). Every handler below receives a loaded, authorized order — the guarantee is structural, not remembered.
Trade-off: implicitness — a reader of getOrder sees req.order with no local origin (9.1's hidden coupling, deliberately traded for DRY and safety). Mitigations: keep param handlers few and adjacent to their router, name them for what they guarantee, and declare the attachment through module augmentation (3.7.6) so req.order is typed rather than any — the compiler then documents the implicit contract.
InterviewWhat does trust proxy do, and what exactly breaks when it is wrong in each direction?
It tells Express how many upstream hops may be believed when reading X-Forwarded-For/X-Forwarded-Proto, which in turn defines req.ip, req.ips, req.protocol, and req.secure. Left unset behind a proxy, which is the common mistake, four things go wrong:
req.ipis the load balancer's address. So rate limiting puts every client into one bucket, and the limiter either blocks everyone or nobody (9.7.5).- Access logs and audit trails record the proxy's address, so you can no longer tell who did what.
req.protocolis"http", soreq.secureis false. Session cookies markedsecure: trueare silently not set, and login seems to fail at random.- Middleware that redirects to HTTPS keeps redirecting forever.
Set too permissively (true = trust every hop): any client can send X-Forwarded-For: 1.2.3.4 and Express believes it — IP-based rate limits, allow-lists, and geo-rules are bypassed at will, and logs are poisoned with attacker-chosen addresses (Part 8.5's X-Forwarded-* spoofing). Correct: set it to the actual number of proxies in front of the app (1 for a single LB, 2 for CDN → LB) or an explicit list of trusted subnets — a deployment fact, so it lives in configuration and changes when the topology does.
StaffDesign the routing and request-context conventions for a 40-endpoint Express service that three teams contribute to. Specify the structure, the attachment budget, and the review rules.
Structure: one router per resource, owned by its feature team. Each routes/<resource>/index.ts exports a router. At its top, in a fixed order, it declares four things: its own authenticate (or an explicit public marker), its authorize policy, its body-size limits and its rate limits. So any route any team adds gets the same protection automatically, and a reviewer can check a whole feature by reading that one block.
Concerns that must apply to everything, such as helmet, CORS, the traceId and the error handler, stay in app.ts in the standard order (9.9.1). Nothing security-related is added handler by handler, because protection added per handler is protection someone will forget.
Nested resources use child routers with mergeParams: true, plus a Router.param loader for the parent id. So every /:orderId/items/* handler receives req.order already loaded and permission-checked, without repeating the lookup.
A fixed, typed list of what may be attached to a request. Exactly four are allowed:
req.user, set byauthenticate.req.<resource>, set by the param loaders.res.locals.traceId, set by the context middleware.res.locals.flags.
Each one is declared in a single types/express.d.ts file (3.7.6), so the compiler both documents and enforces the list. Anything else is passed as a normal argument. app.locals is read-only after startup. A lint rule forbids writing to it outside app.ts, because request data stored there leaks between users. That is a security bug, not a style issue.
Review rules that can be checked:
- No route may be added outside a resource router.
- Every path parameter that names a resource has a
Router.paramloader, which returns 404 if it does not exist and checks permission. trust proxycomes from config, is never hard-coded, and is checked at startup against how the service is actually deployed.- Path parameters say which thing. Query parameters adjust the request. Putting the thing's identity in the query blocks the review (9.6.1).
- Handlers may not read
X-Forwarded-*,HostorRefererdirectly. They must use the framework's trusted accessors.
The metric to track: number of routes reachable without passing a router-level guard — the target is zero, and it's grep-able in CI, which turns "we're careful about auth" into a build check.
Flashcards
FlashRouter = ?
Mini-app (own stack + routes), mounted as middleware; mount path stripped (url/baseUrl/originalUrl); nests with mergeParams; feature-owned = protection by construction.
FlashParams vs query
Path params identify (required, cacheable resource, real 404s); query modifies (filter/sort/page/fields). Both are strings — validate at the edge.
FlashRouter.param()
Runs once per request on matched routes: validate → fetch → 404 → authorize → attach req.order. DRY + structural safety; cost = implicitness (type it).
Flashuse vs all
router.use matches path PREFIXES (/orders hits /orders/9/items); router.all matches the EXACT path, any method.
Flashlocals pair
app.locals = process-lifetime (request data here leaks across users). res.locals = request-scoped (user, traceId, view vars).
Flashtrust proxy
Number of hops to believe. Unset behind LB: wrong req.ip (limiter useless), bad logs, req.secure false (secure cookies fail, redirect loops). true = XFF spoofable.
Scenario Drill
DrillProduction incident: after moving from a single VM to CDN → ALB → app, three things break simultaneously — the login endpoint's rate limiter now blocks all users after a few minutes, session cookies stop being set for some users, and the fraud team reports every request logged from one IP. Diagnose the single root cause, prescribe the fix precisely, and list the follow-on hardening the incident should trigger.
Single root cause: trust proxy is unset (or wrong) for the new two-hop topology. All three symptoms are the same fact — Express is reading connection-level values instead of forwarded ones. 1. The limiter blocks everyone. req.ip is the load balancer's address, so express-rate-limit keeps one bucket for the entire internet. A few hundred real logins use it up, and every user gets a 429 (9.7.5). 2. Cookies are not set. HTTPS ends at the CDN or load balancer, so the app sees plain HTTP. req.secure is false, and express-session with cookie: { secure: true } refuses to set the cookie. Those users look logged out right after logging in. It is only "some users", because some may come through a path where the cookie survived, or the rollout is only partly done. 3. Every log line shows the same IP. Same cause. Audit trails and fraud checks can no longer see who is who.
The exact fix: app.set("trust proxy", 2). That is the real number of hops, CDN then load balancer. Read it from configuration, TRUSTED_PROXY_HOPS, so a change in the network becomes a reviewed config change, not a hunt through the code. Do not use true, because then any client could fake X-Forwarded-For and get around IP limits and allow-lists (Part 8.5). Check the value at startup. In staging, also send a request through the real chain from a known IP, and confirm that req.ip shows that IP. That check would have caught this before production.
Further hardening this incident should lead to:
- Rate-limit by user, not by IP, whenever you know the user. IP is a weak key anyway, because a whole office can share one IP and get punished together (9.7.5). Use the IP only before login.
- The CDN or load balancer must remove and rewrite
X-Forwarded-Forfrom the client. Trusting a hop count is only safe if the outermost proxy overwrites whatever the client sent. Otherwise the client can write fake hops, and the count means nothing. - Alert on sudden changes in the limiter's rejection rate. A jump to 100% rejections should have paged someone within minutes (9.7.5).
- Set a target for the login success rate. It would have caught the cookie failure even if no user had reported it.
- Add a deployment checklist item: "settings that depend on the proxies, such as
trust proxy, cookiesecure, CORS origins and redirect base URLs, are checked again whenever the network in front of the app changes." Each of those settings is an assumption about a network the app cannot see.