Suppose Alice belongs to workspace alpha. She requests /workspaces/alpha/documents/doc-b/, and the response contains a document from beta. The server recognized Alice and checked her workspace membership. Both checks passed. Where did the request go wrong?
This teaching example uses fictional documents and identities. The runnable example requires Node.js 22 or later; the reading method also applies to other languages and frameworks. The useful question is specific: which workspace was checked, and which workspace constrained the document lookup?
Start with one request and the rule it should satisfy
Keep the method, path, identity, and expected result together:
GET /workspaces/alpha/documents/doc-b/
Demo identity: alice, a member of alpha only
Stored document: doc-b belongs to beta
Expected: 404, with no document in the response
Broken result: 200, containing Beta release notes
The example’s policy grants workspace members access to all documents in that workspace. There are no document-specific shares or administrator exceptions. For a member of alpha, a document outside alpha gets the same status and body as a missing document. A request naming a workspace the user hasn’t joined gets 403 before the document store is called.
That gives you two relationships to follow: the authenticated user must belong to the URL’s workspace, and the returned document must belong to that same workspace. A successful check of the first relationship leaves the second to be enforced by the data lookup.
In a real report, also record the application build or source version. The route in your working copy needs to match the code that handled the request. Use a synthetic fixture when reproducing the problem locally.
Find the route registration before opening the handler
Download request-flow.mjs and request-flow.test.mjs into the same folder. Search for the path’s distinctive nouns, such as workspaces and documents, using your editor’s text search. If you have ripgrep, these commands narrow the next steps:
rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs
Open createApp. Its route object puts the method, path pattern, middleware, and selected handler next to each other:
const route = {
method: 'GET',
pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
middleware: [authenticate, requireWorkspaceMember],
handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};
Follow the next few lines too. handle matches new URL(request.url).pathname, checks the method, and assigns the two captured segments to workspaceId and documentId. Query parameters don’t supply either value. The example accepts lowercase letters, digits, and hyphens, and requires the trailing slash shown above; it isn’t a general-purpose router.
In a framework application, the complete path may be split between a parent mount such as /workspaces/:workspaceId and a child route such as /documents/:documentId. Read the mount and confirm how parent parameters reach the child. A search hit in a handler doesn’t tell you which middleware wraps its caller.
Read middleware in the order it actually runs
runMiddleware uses reduceRight to wrap the handler. With [authenticate, requireWorkspaceMember], authentication runs first, then membership, then the handler when each step calls next(). The return next() values flow back to handle, whose catch turns a thrown HttpError into a response.
Request → path and method match → authenticate
→ requireWorkspaceMember → document lookup → JSON Response
↘ thrown error → handle's catch → error Response
authenticate looks up the request’s Authorization header in a fixed map and puts the result in ctx.user. The labels demo-alice and demo-bob are public fixture inputs standing in for a verified session. This is enough to follow where the principal comes from in the example; a real application’s session or token verification must be read separately.
Now open the membership middleware:
async function requireWorkspaceMember(ctx, next) {
const { workspaceId } = ctx.params;
const allowed = ctx.user.workspaceIds.includes(workspaceId);
ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
if (!allowed) throw new HttpError(403, 'Forbidden');
return next();
}
For Alice and a path naming alpha, allowed is true. Nothing in this function examines documentId or loads a document. Keep that exact conclusion in your notes: “Alice may enter the alpha route.” If you write only “authorized,” it’s easy to read more into the check than it does.
Also mark where execution stops. A missing or unknown demo identity produces 401 before membership. A non-member gets 403 before the document handler. If middleware calls next() before making its decision, the handler may already have run; the placement of that call matters as much as the middleware’s name.
Keep the user, requested workspace, and stored document separate
For the failing request, a useful reading note fits in four rows:
| Value | Where it comes from | Value on this request | Where to follow it |
|---|---|---|---|
| User and memberships | authenticate reads the fixture identity map |
Alice, ['alpha'] |
requireWorkspaceMember |
| Requested workspace | First route capture | alpha |
Membership check, then lookup arguments |
| Requested document | Second route capture | doc-b |
Document lookup |
| Document’s actual workspace | Stored row | beta |
Lookup predicate and response |
Add file and line locations as you inspect each producer and consumer. In a larger application, these may be different types in different packages, but the distinction stays useful: the URL expresses the request, the identity supplies memberships, and the stored row supplies ownership.
The two IDs arrive together. Now see whether they leave the handler together.
Open the lookup, even when the handler looks ordinary
The deliberately unsafe handler reads:
async function getDocumentUnsafe(ctx) {
ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
const document = await ctx.store.findById(ctx.params.documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
Only documentId reaches the store. Follow findById into createStore: its predicate is document.id === documentId. There is no workspace comparison and no later ownership check before serialization. The earlier middleware accepted alpha; this lookup can return a row from any workspace.
The error needs no race or forged session. Alice keeps her own identity and the allowed alpha path, then supplies the fixture’s doc-b ID. The application checks one resource scope and reads another.
A helper name by itself would be weaker evidence in a real project. A repository method could add a tenant filter internally, inherit a scoped database session, or depend on database row-level policies. Read the selected implementation and its configuration before concluding that an omitted argument means an unscoped query. Here the in-memory predicate is the entire lookup, so you can check it directly.
Run the counterexample through the whole handler chain
From the folder holding both downloads, run:
node request-flow.mjs
node --test request-flow.test.mjs
No install step, account, or service is needed. makeRequest constructs a Web Request, and the tests pass it to the app’s handle function, which returns a Web Response. The example.invalid URL is only parsed in memory. The example never calls fetch or opens a listening socket.
The demo prints these response lines, with the visited steps and recorded store calls underneath each one:
normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}
For the broken request, the visited steps are:
request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200
The trace array is instrumentation added to this fixture, kept outside the response. The test also checks the returned document and the recorded call to findById('doc-b'). Those assertions connect the branch sequence to the data that actually came back.
The test named reproduce: passes when the unsafe implementation returns beta’s document. Its green result confirms the defect can be reproduced. A separate test requires the fixed implementation to return 404 for the same request.
Carry the checked workspace into the query
The corrected handler keeps both route values:
async function getDocument(ctx) {
const { workspaceId, documentId } = ctx.params;
ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
const document = await ctx.store.findInWorkspace(workspaceId, documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
Inside findInWorkspace, the predicate requires both document.workspaceId === workspaceId and document.id === documentId. The membership middleware has already checked that same workspaceId. The fixture doesn’t mutate ctx.params between those steps, and the documents and memberships remain fixed during each run.
For /workspaces/alpha/documents/doc-b/, the lookup now has no match. Both an absent ID and a foreign-workspace ID produce 404 with {"error":"Not found"}. That equal status and body avoid explicitly revealing which of those cases occurred; the tests make no claim about timing or other side channels.
Checking document.workspaceId after a global read could also stop this particular response leak. A scoped lookup makes the restriction explicit at the store call and returns no foreign row to this handler. In this in-memory example, Array.find still examines rows to evaluate its predicate. The recorded calls show query arguments, not database I/O counts or physical isolation.
If you map this to SQL or an ORM, inspect the emitted condition and parameter binding, and test with the real data layer. Membership revocation, document moves, shared documents, and other routes would need their own rules and checks.
Test the successful path and the places execution must stop
The suite contains ten tests: one intentional reproduction and nine checks of the fixed app. Several tests exercise more than one input:
| Request or condition | Expected response | Store behavior to check |
|---|---|---|
Alice reads alpha/doc-a; Bob reads beta/doc-b |
200, correct document |
Both IDs reach findInWorkspace |
Alice reads alpha/doc-b |
404, no document |
Lookup remains scoped to alpha |
Alice reads alpha/missing |
Same status and body as the previous row | No matching document |
| Missing or unknown demo identity | 401 |
No store call; membership not reached |
Alice reads beta/doc-b |
403 |
No store call |
Alice reads alpha/doc-b?workspaceId=beta |
404 |
Query parameter cannot replace route scope |
| Unknown path; wrong method on a known path | 404; 405 with Allow: GET |
No store call |
| Store throws an unexpected error | 500, generic error body |
Error reaches the response wrapper |
The shorthand paths in the table expand to /workspaces/{workspaceId}/documents/{documentId}/, with the query string after the trailing slash. The two successful reads prevent a “fix” that simply rejects every request. The no-call assertions catch an implementation that rejects only after accessing the document store. Together they check more than final status codes.
These tests cover this route’s in-process request handling, middleware order, lookup arguments, and JSON responses. They don’t exercise HTTP transport, reverse proxies, production identity verification, or a database. When applying the method to an application, begin at its actual HTTP adapter and verify that it selects the handler and middleware you traced.
Leave a note someone else can verify
Write the result around the request and the exact points where its values are used:
Trigger: Alice + GET /workspaces/alpha/documents/doc-b/
Route: createApp captures alpha and doc-b.
Membership: requireWorkspaceMember allows Alice for alpha.
Old read: getDocumentUnsafe passes only doc-b to findById.
Observed: 200 returns a row whose workspaceId is beta.
Fix: getDocument passes alpha and doc-b to findInWorkspace.
Regression: same request returns 404; both valid member reads still return 200.
Scope: in-memory request pipeline; production adapter and storage are untested.
Attach the source locations and test command. If a colleague asks “but doesn’t this route already have authorization?”, the note shows exactly what that check covered and where the workspace stopped being used.