RoutingGENERALFRAMEWORK-SPECIFICDATABASE-SPECIFIC

Path Parameters

A path parameter is an attacker-supplied string that happens to be positioned where you expected an identifier.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

A path segment is a variable. What has to happen to it before a handler can use it?

The requirement

GET /orders/{orderId} should return that order. The id comes from the URL, and the URL comes from whoever is calling.

The obvious build

The router captured it, so read req.params.orderId and pass it to the query. The framework already did the extraction work.

Why it breaks

The parameter is abc. Your ORM builds WHERE id = 'abc', Postgres raises invalid input syntax for type uuid, and the client receives a 500 for what is unambiguously a client error (Reporting Validation Failures).

How it breaks in production
  • The parameter is abc. Your ORM builds WHERE id = 'abc', Postgres raises invalid input syntax for type uuid, and the client receives a 500 for what is unambiguously a client error (Reporting Validation Failures).
  • The parameter is 42abc. parseInt returns 42 and you serve order 42 for a URL that does not exist, which means two different URLs are now the canonical address of one resource.
  • The parameter is 99999999999999999999. It exceeds the range of a 64-bit integer, and depending on the language it becomes a float, wraps, or throws — none of which the handler expected.
  • The parameter is another customer's order id, and it works, because the route was authenticated but the object was never authorised (Object-Level Authorization).
  • A catch-all parameter — /files/*path — receives ../../etc/passwd or a percent-encoded variant of it, and the handler joins it onto a base directory (Path Traversal).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Captured parameters are always strings. There is no type in a URL. Any integer, UUID, date or enum you believe you have is the result of a conversion you either wrote or inherited from a framework default.
  • Percent-decoding is where the subtle bugs live. %2F is an encoded /. If the router decodes the whole target and *then* splits on /, a single parameter can silently become two segments and match a different route. If it splits first and decodes each segment, %2F stays inside one parameter — which is the safer behaviour and is what most modern routers do.
  • Some frameworks let you constrain a parameter in the pattern itself — a regex, a type token, a route constraint. That is a matching rule, not a validation rule: a non-matching path becomes "no route" (404), not "bad input" (400). Which of those you want is a product decision.
  • Unicode adds a second normalisation problem on top of percent-encoding: two different byte sequences can render as the same visible identifier. Comparing a decoded parameter against a stored string without deciding a normalisation form is comparing two things you have not defined.
  • A parameter is a claim: "the caller says they want object X". It is never evidence that the caller may have object X.

It is a string until you make it something else

The single highest-value habit in this lesson is converting the raw segment into a domain type at the top of the handler, and treating failure to convert as a client error rather than an exception. It removes a whole class of 500s permanently, and it makes every downstream signature honest: a function taking OrderId cannot be called with a query string.

The version people write instead is not lazy — it is shorter and reads fine. It just moves the failure from a place you control to a place you do not.

Two handler openings
Cast and hope
app.get('/orders/:orderId', async (req, res) => {
  const id = Number(req.params.orderId)   // NaN for 'abc', 42 for '42abc'
  const order = await repo.findById(id)   // driver error becomes a 500
  res.json(order)                          // and whose order is it?
})
Parse, then authorise the object
app.get('/orders/:orderId', async (req, res) => {
  const parsed = parseOrderId(req.params.orderId)
  if (!parsed.ok) return res.status(400).json({ error: 'invalid_order_id' })

  const order = await repo.findById(parsed.value)
  if (!order) return res.status(404).json({ error: 'not_found' })
  if (!canRead(req.ctx.principal, order)) return res.status(404).json({ error: 'not_found' })

  res.json(toOrderResponse(order))
})

Three distinct outcomes — malformed input, no such object, not yours — now have three distinct code paths and can have three distinct policies. In the first version all three are the same unhandled path, and the third one succeeds.

The encoded slash, and why decode order matters

PROTOCOL-SPECIFICRFC 3986 reserves / as a segment delimiter, so %2F is deliberately not equivalent to /. Servers differ anyway: some proxies reject encoded slashes outright, some normalise them, some pass them through. Whether it reaches your router at all is a property of the deployment, not of the framework.

A path parameter is defined over a single segment. That definition only holds if the router splits the path into segments before percent-decoding each one. If it decodes the whole target first, an encoded / inside a parameter becomes a real separator and the request matches a different route than the client addressed.

This is not theoretical: it is a recurring source of authorisation bypasses when a proxy and an application disagree about it. The proxy sees one path and applies one rule; the application sees another path and dispatches to another handler. The defence is not clever parsing — it is putting the authorisation decision in the process that also makes the routing decision.

The same bytes, two readings
1GET /files/reports%2F2026%2Fq1.pdf HTTP/1.1
2
3# split-then-decode -> one segment
4# route /files/:name
5# name = "reports/2026/q1.pdf"
6
7# decode-then-split -> three segments
8# /files/reports/2026/q1.pdf
9# may match /files/*path, or no route at all, or a route
10# the proxy in front of you never evaluated

Whichever behaviour your stack has, the failure mode is the mismatch — one hop reading it one way and the next hop reading it the other. Test it against a real request rather than reasoning about it.

The parameter is a claim, not a credential

Every failure below has the same shape: the request was authenticated, the route was correct, the parameter was well-formed, and the answer was still wrong because nothing checked the relationship between the caller and the object named in the URL.

It is worth being precise about why this survives review. The handler looks complete. Authentication happened in middleware, so a reader sees an authenticated request and a valid id. The missing step is invisible because it was never written down.

TriggerSymptomCauseResponse
Client changes /orders/41 to /orders/42Another customer's order is returned with a 200Route was authenticated; the object was never authorisedCheck ownership on the loaded row, in the layer that loads it (Object-Level Authorization)
Client sends /orders/abc500 with a database driver error in the logsRaw string passed to a typed column with no parse stepParse at the boundary; return 400 with a field-level error (Transport Validation)
Client sends /tenants/other/reportsCross-tenant read succeedsTenant taken from the path instead of from the authenticated principalDerive the data scope from the session, and treat a path tenant as a value to compare against it (Multi-Tenancy)
Client sends /files/..%2f..%2fetc%2fpasswdArbitrary file readCatch-all parameter joined onto a base path without containment checkResolve to an absolute path and assert it is inside the root; reject otherwise (Path Traversal)
Sequential ids plus distinct 403 and 404Steady low-rate scan across the id spaceResponse codes disclose existenceAnswer 404 for both, and alert on denial rate per principal (Audit Logs for Privileged Actions)

How to build it

Most important first.

  • Parse at the boundary into a typed value, and let the parse failure be the 400. One function per id type — parseOrderId(raw): OrderId | ParseError — used by every handler that takes one (Parse, Do Not Validate).
  • Decide, once, whether a malformed id is 400 or 404, and apply it everywhere. 400 is more honest; 404 leaks less about which ids exist. Both are defensible; inconsistency is not.
  • Authorise the object after loading it, not the route before. "Is this caller allowed to see order 4711" cannot be answered without order 4711 (Object-Level Authorization).
  • Constrain the shape in the route pattern as well when your framework supports it, so /orders/abc never reaches the handler at all — but never as your only defence, because not every framework does this and route constraints do not travel with the code.
  • Avoid catch-all parameters unless you are serving a genuinely hierarchical namespace. If you must, resolve the final path and assert it is inside the intended root, rather than inspecting the input for ...
  • Prefer opaque, non-sequential identifiers where enumeration matters. This is not authorisation, and it is not a substitute for it — it changes the cost of discovery, nothing more.

What can go wrong

Failure modes
  • Casting failures surfacing as 500s: the single most common way a validation gap becomes an availability metric.
  • A leading-zero or leading-plus integer (007, +7) accepted by the cast, producing multiple URLs for one resource and breaking caches and idempotency keys built from the path.
  • Double-decoding: the proxy decodes once, the framework decodes again, and %252F becomes / two hops later than anyone expected.
  • A route constraint that "validates" the id, plus a second route without the constraint added months later, so the guarantee quietly stops holding.
  • Trusting a parameter that is a tenant id. Any id that selects a data scope must be checked against the caller's identity, never used as given (Tenant Isolation).
What can race
  • The object referenced by a parameter can be deleted or reassigned between the authorisation check and the read. Authorise on the row you actually load, in the same transaction, rather than on a row you loaded earlier (Backend Races).
  • A tenant reassignment committed concurrently with a request means the caller's permission cache and the row's current owner disagree for the length of the cache TTL.
Security
  • Insecure direct object reference is the flagship: authentication succeeded, the route ran, and the object belonged to someone else. It remains one of the most common serious backend vulnerabilities in the field (Broken Access Control (IDOR / BOLA)).
  • Path traversal via catch-all parameters, including encoded and double-encoded forms, and including Windows separators when the service ever runs there (Path Traversal).
  • Injection when a parameter reaches a query, a shell, a file path or a URL by concatenation. A parameterised query fixes the SQL case and nothing else (SQL Injection, Command Injection).
  • Server-side request forgery when a parameter is a hostname or an id used to build an outbound URL (SSRF — When the Backend Fetches a URL).
  • Enumeration: sequential ids plus a route that answers differently for "exists but forbidden" and "does not exist" is a listing endpoint you did not intend to build.
Misreads
  • "The route pattern validates it." A pattern decides matching. /orders/:id with no constraint matches /orders/DROP perfectly happily.
  • "UUIDs are secure." They are unguessable, which raises the cost of discovery. They authorise nothing; a leaked or shared UUID is a working key (Broken Access Control (IDOR / BOLA)).
  • "The frontend only ever sends ids it got from us." The frontend is one client. curl is another, and so is the same frontend after a user edits the URL bar.
  • "Sanitising .. prevents traversal." Encoded forms, absolute paths, symlinks and alternate separators all bypass string inspection. Resolve the path and check containment.
  • "It is a number, so it is safe." Range, sign, leading zeros and locale-specific parsing are all still open questions.

Operating it

How you see it in production
  • Count 400s by route template and by parameter name. A single parameter producing most of your 400s is usually a documentation or SDK problem, not an attack.
  • Alert on 500s whose exception type is a cast or parse error. Every one of those is a missing boundary parse, and they are cheap to eliminate permanently.
  • Log authorisation denials with the object id and the caller, not just a count. The pattern "one principal, many ids, all denied" is enumeration in progress (Audit Logs for Privileged Actions).
  • Watch for requests where the decoded path differs from the raw path. It is rare in legitimate traffic and common in probing.
What changes at 10x and 100x
  • Nothing about parsing changes with load; the cost is per-request and tiny. What changes is that at 100x traffic a small percentage of malformed ids becomes a steady stream of 500s that masks real incidents.
  • Enumeration becomes practical at scale on the attacker's side, not yours: sequential integer ids across a large table are exhaustively walkable in hours.
  • If ids are opaque and you look them up through a secondary index, the index cost is per-request and per-instance; that is a database question, not a routing one (Should I Add an Index?).
What this costs
  • Parsing at the boundary means a type per identifier and a small amount of ceremony on every handler. On a five-endpoint service that is real overhead for little gain; on a fifty-endpoint service it is the only thing keeping cast errors out of the 500 bucket.
  • Opaque identifiers cost human debuggability — support tickets stop containing "order 41" and start containing a UUID — and they add an index.
  • 404 for a malformed id hides existence but produces genuinely confusing developer experience for your own client teams.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThat a path parameter is an untrusted string requiring an explicit parse and an object-level authorisation check is true of every stack.
  • FRAMEWORK-SPECIFICConstraint syntax and decoding order differ: ASP.NET Core and Django support inline type/regex constraints that make a non-matching path a 404; Express has no built-in constraints and hands you a raw string; Go's 1.22 mux wildcards are untyped, while {name...} matches the remainder including slashes. Check whether your router decodes before or after splitting segments — that single detail decides whether %2F can cross a segment boundary.
  • DATABASE-SPECIFICPostgres rejects a malformed uuid or integer with an error that surfaces as a 500 unless you parse first; MySQL in non-strict mode has historically coerced '42abc' to 42 with a warning, so the same missing parse produces a wrong answer instead of an error. Coercion behaviour is a property of the engine and its mode, not of your code.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.