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 (process-lifetime — request data here leaks across users) vs res.locals (request-scoped — user, traceId, view vars). trust proxy = how many hops to believe: unset behind an LB breaks
req.ip(rate limits key on one IP), logs,req.secure(secure cookies fail, redirect loops);trueallowsX-Forwarded-Forspoofing — set the actual hop count.
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)). Mechanically it is middleware — which is why it composes, nests (with mergeParams: true when a child needs the parent's params), mounts conditionally, and can be tested standalone with supertest. Inside a mounted router, paths are relative: req.url has the mount prefix stripped, req.baseUrl holds that prefix, and req.originalUrl holds the full path (the trio that confuses logging middleware until you know it). It's the right modularity unit because it aligns code structure with the security and validation posture: router.use(authenticate) and router.use(express.json({ limit })) at the top mean every route in the file inherits them structurally — a future developer cannot add an unguarded route — which converts a discipline problem into a design property (9.3.6 OCP), and lets a reviewer verify a feature's protection by reading one file (9.1's cohesion at folder scale).
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).
Caching consequence: a distinct path is a distinct cacheable resource with its own ETag and validators (9.6.3) and a meaningful 404 when absent; a query-string filter (/orders?id=9142) is a collection view — caches treat the whole query string as part of the key, 404 is semantically wrong (an empty list is the honest answer), and per-resource invalidation becomes impossible. 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. Unset behind a proxy (the common default failure): req.ip is the load balancer's address, so rate limiting keys every client into one bucket (9.7.5 — the limiter now either blocks everyone or nobody), access logs and audit trails record the proxy (forensics gone), req.protocol is "http" so req.secure is false — session cookies with secure: true are silently not set (login "randomly" fails), and HTTPS-redirect middleware loops 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, feature-owned: routes/<resource>/index.ts exports a router that declares, at its top and in fixed order, its own authenticate (or explicit public marker), authorize policy, body-parser limits, and rate limits — so a route added by any team inherits the posture structurally, and a reviewer verifies a feature by reading one header block. Cross-cutting concerns that must apply to everything (helmet, CORS, traceId, error handler) stay in app.ts in the standard order (9.9.1); nothing security-relevant is registered per-handler, because per-handler protection is protection someone can forget. Nested resources use child routers with mergeParams: true and a Router.param loader for the parent id — so /:orderId/items/* handlers receive a loaded, authorized req.order without repeating lookups. Attachment budget — the explicit list, typed: exactly four request attachments are permitted (req.user set by authenticate; req.<resource> set by param loaders; res.locals.traceId set by the context middleware; res.locals.flags), each declared via module augmentation in one types/express.d.ts (3.7.6) so the compiler both documents and enforces them; anything else is passed as an argument. app.locals is read-only after boot — a lint rule forbids assignment outside app.ts, because request data there is a cross-user leak, not a style issue. Review rules, checkable: (1) no route may be added outside a resource router; (2) every param that identifies a resource has a Router.param loader that 404s and authorizes; (3) trust proxy is set from config, never hard-coded, and asserted at boot against the deployment topology; (4) path params for identity, query params for modifiers — identity-in-query is a review block (9.6.1); (5) handlers may not read X-Forwarded-*, Host, or Referer directly — only through 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) Limiter blocks everyone: req.ip is the ALB's address, so express-rate-limit maintains one bucket for the entire internet; a few hundred legitimate logins exhaust it and every user is 429'd (9.7.5's keying, defeated by infrastructure). (2) Cookies not set: TLS terminates at the CDN/ALB, so the app sees plain HTTP; req.secure is false, and express-session with cookie: { secure: true } refuses to set the cookie — users on that path appear logged out immediately after login (and only "some users" because a subset may hit a path where the cookie survived, or a mixed rollout is in play). (3) One IP in logs: same cause, so audit trails and fraud analysis are blind.
Fix, precisely: set app.set("trust proxy", 2) — the actual hop count (CDN → ALB), sourced from configuration (TRUSTED_PROXY_HOPS) so a topology change is a config change with a review, not a code archaeology exercise; not true, which would let any client forge X-Forwarded-For and bypass IP limits and allow-lists (Part 8.5). Verify with an assertion at boot and a smoke test that asserts req.ip equals a known synthetic client IP through the real chain — a test that would have caught this in staging.
Follow-on hardening the incident should trigger: (a) rate-limit keys should prefer authenticated identity over IP wherever a user is known (IP is a weak key anyway — corporate NAT punishes whole offices, 9.7.5), with IP as the pre-auth fallback; (b) the CDN/ALB must strip and re-write X-Forwarded-For from the client edge (trusting hop count is only safe if the outermost proxy overwrites client-supplied values — the trust chain must be terminated at the edge, or the hop count is a fiction); (c) alerting on limiter rejection rate deltas (a 100%-rejection cliff should have paged in minutes — 9.7.5's ops note); (d) a login-success-rate SLO, which would have detected the cookie failure independently of anyone reporting it; and (e) a deployment checklist item — "proxy-dependent settings (trust proxy, cookie secure, CORS origins, redirect base URLs) are re-verified whenever the ingress topology changes," because every one of them is a claim about a network shape the app cannot observe.