Path Parameters
A path parameter is an attacker-supplied string that happens to be positioned where you expected an identifier.
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.
A path segment is a variable. What has to happen to it before a handler can use it?
GET /orders/{orderId} should return that order. The id comes from the URL, and the URL comes from whoever is calling.
The router captured it, so read req.params.orderId and pass it to the query. The framework already did the extraction work.
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
abc. Your ORM buildsWHERE id = 'abc', Postgres raisesinvalid 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.parseIntreturns 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/passwdor a percent-encoded variant of it, and the handler joins it onto a base directory (Path Traversal).
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.
%2Fis 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,%2Fstays 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.
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?
})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
/ 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.
1GET /files/reports%2F2026%2Fq1.pdf HTTP/1.12 3# split-then-decode -> one segment4# route /files/:name5# name = "reports/2026/q1.pdf"6 7# decode-then-split -> three segments8# /files/reports/2026/q1.pdf9# may match /files/*path, or no route at all, or a route10# the proxy in front of you never evaluatedWhichever 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
Client changes /orders/41 to /orders/42 | Another customer's order is returned with a 200 | Route was authenticated; the object was never authorised | Check ownership on the loaded row, in the layer that loads it (Object-Level Authorization) |
Client sends /orders/abc | 500 with a database driver error in the logs | Raw string passed to a typed column with no parse step | Parse at the boundary; return 400 with a field-level error (Transport Validation) |
Client sends /tenants/other/reports | Cross-tenant read succeeds | Tenant taken from the path instead of from the authenticated principal | Derive 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%2fpasswd | Arbitrary file read | Catch-all parameter joined onto a base path without containment check | Resolve to an absolute path and assert it is inside the root; reject otherwise (Path Traversal) |
| Sequential ids plus distinct 403 and 404 | Steady low-rate scan across the id space | Response codes disclose existence | Answer 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/abcnever 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
- 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
%252Fbecomes/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).
- 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.
- 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.
- "The route pattern validates it." A pattern decides matching.
/orders/:idwith no constraint matches/orders/DROPperfectly 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.
curlis 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
- 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.
- 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?).
- 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%2Fcan 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.