A handler without an inline permission check may still be protected by middleware. To decide whether an authorization alert is a false positive, trace the exact registered route through every guard that runs before the handler, then check that the guard authorizes this actor to act on this particular object. A generic login gate is not an ownership check. If another route reaches the same sensitive action without the guard, the original alert may be too broad while the underlying problem remains real.
Middleware is part of the request path, not an annotation on a function. Express describes an application as a series of middleware calls during the request-response cycle, and its routing guide shows multiple callbacks handing control to the next callback. OWASP recommends denying by default and validating permissions on every request. The practical task is to establish which calls actually run and what they enforce.
Illustrative case: a private support ticket
Illustrative repository: Harbor Helpdesk. A static alert points to a handler named showCase. It reads a case by ID and returns the customer's private support notes. The handler itself does not call an authorization function, so the alert says any signed-in user can read another customer's case.
The service owner shows this simplified route setup:
const cases = express.Router();
cases.use(authenticate);
cases.get("/:caseId", loadCase, canReadCase, showCase);
app.use("/api/cases", cases);
The source suggests that a request to GET /api/cases/:caseId passes authenticate, loadCase, and canReadCase before showCase. That is a reason to investigate the control, not yet a reason to close the alert. The reviewer must confirm the router is mounted in the deployed application, that canReadCase denies unauthorized users rather than merely logging, and that the permission decision uses a trusted identity and the case actually loaded for this request.
The case gets harder when a separate legacy router contains GET /api/cases/:caseId/export. It may read the same notes through a different handler. The first route's middleware says nothing about the export route. Review each route and caller independently.
Follow the effective middleware chain
Start with the exact HTTP method and URL in the alert. Find the application entry point, the router mount, any nested routers, and the matching route callback list. Framework registration, route parameters, mount paths, and fallback handlers all matter. A handler may be called from more than one route or from a job rather than HTTP. A global app-level guard covers a route only when the relevant registration and execution path actually include it.
For Harbor Helpdesk, record the chain as an artifact:
| Position | Evidence to inspect | Decision it must make |
|---|---|---|
| 1. Router mount | Application entry point mounts cases under /api/cases in production build. | Is this the route that serves the request? |
| 2. authenticate | Identity comes from a validated server session. | Is the caller authenticated, and does failure stop the request? |
| 3. loadCase | Looks up the requested case and binds it to this request. | Is the object ID the same one showCase returns? |
| 4. canReadCase | Compares current member and case organization or an explicit grant. | Does a nonmember receive a denial before showCase? |
| 5. showCase | Returns only the authorized case projection. | Can it access a different case or bypass an earlier denial? |
Do not infer a guarantee from names. A function called canReadCase could always call next, use a client-supplied organization header, or check only whether a user is a support agent. The specific CWE-862 missing-authorization pattern concerns missing authorization for a resource or action; proving “some middleware exists” does not settle that question.
Read the guard that the name hides
Use two synthetic organizations: Bea belongs to B; case C7 belongs to A. Assume authenticate supplies a verified member and loadCase supplies the authoritative case record. This small Express-style illustration enforces the case's organization and stops on denial:
function canReadCase(req, res, next) {
if (!req.case || req.member.organizationId !== req.case.organizationId) {
return res.sendStatus(403);
}
return next();
}
function showCase(req, res) {
return res.json({ id: req.case.id, notes: req.case.notes });
}
This is a deliberately simple one-organization policy, not a complete support-permissions implementation. For Bea/C7, B differs from A, so the guard returns the denial and never calls next. For an authorized A member, showCase returns the same checked object. Express's middleware guide distinguishes ending the response from passing control onward; the explicit return makes this branch easy to inspect.
Now replace the comparison with req.member.organizationId !== req.query.organizationId. Bea can supply B, pass that comparison and still receive loaded case C7 from A. The route order is correct; the object binding is wrong. A client-selected organization cannot establish the loaded case's owner.
There is another failure even with the good guard. Suppose an export handler checks C7 but then loads req.query.exportCaseId and returns that second case's notes. The authorization result applies to C7, not the new object. Reuse the authorized object or perform an appropriate decision for the actual exported object before delivery.
| Route variant | Checked object | Returned object | Exercise decision |
|---|---|---|---|
| Normal view with good guard | C7 from authoritative loader | Same C7 | Reject the missing-authorization allegation for this bounded path, once registration and identity assumptions are verified. |
| Guard compares query organization | Request-selected B | Case C7 owned by A | Counterevidence fails; investigate the supported cross-organization path. |
| Export reloads another ID | C7 | Separately selected C9 | View's guard does not settle export. Review C9's decision and delivery. |
These are alternative code paths for reasoning, not executed requests or a claim of an actual independent review. For a real verdict, bind the route, trusted identity provider and loaded object to the assessed build. A middleware diagram without those three bindings is only a map of intended execution.
A bounded validation in an authorized environment
If the team has a controlled environment with test accounts, use two test organizations and a harmless case fixture. Ask for case A as an authorized member and as a member of organization B. Observe the HTTP response and, where available, an authorization decision log that shows which object and actor were evaluated. Repeat for the export route. This is a check of the deployed request chain in that environment; it does not replace source review and does not prove all production routes are safe.
Avoid using a real customer's case ID or notes. The question is whether the application denies the wrong actor before data is returned, not whether a reviewer can exfiltrate sensitive content. If the environment cannot represent the production policy or the relevant route, record that limit instead of reporting a passing result.
Inspect denial and continuation as separate behaviors
A guard must do more than compute a false value. In Harbor, the security property depends on unauthorized requests ending before showCase returns notes. Review the branch from the permission result to the response: does denial stop the chain, or does a later callback still receive control? The source example uses a return on denial so this behavior is visible. Real applications may express the same property through framework-specific error handling; inspect the actual mechanism rather than prescribing identical syntax everywhere.
Trace successful requests too. The object checked by canReadCase must remain the object used by showCase. If a later callback replaces req.case, the earlier comparison may be correct and still irrelevant to the returned notes. Record the assignment locations of the object and the identity, especially where shared middleware mutates request context.
For the synthetic Harbor route, a compact review worksheet can separate those questions:
| Question | Expected property | Failure that would change the verdict |
|---|---|---|
| Where is the member established? | Validated server identity precedes the permission decision | A query field or unverified header replaces the member. |
| Which case is checked? | The authoritative loaded record identifies its organization | The guard compares two request selectors. |
| What happens on denial? | No note-producing handler runs for that request | A continuation still invokes delivery. |
| What happens after permission? | The delivered record remains the checked record | A later lookup selects another case without a new decision. |
This is a review of binding and control flow, not a requirement to duplicate authorization inside every handler. A well-established shared guard can be the correct enforcement point. The goal is to determine whether that guard actually governs the sensitive operation across the assessed registrations.
Review the route when wiring changes
A harmless-looking router refactor can change the applicability of otherwise unchanged middleware. Before carrying the h31 rejection into a new release, compare the mount, callback order, loader, permission inputs and handler call. If only the source line numbers move and these relationships remain the same, preserve the old reasoning with a new binding. If the export is newly routed around canReadCase, open a distinct investigation for that path.
Ask the owning engineer for a route inventory tied to the selected build, not a screenshot of the router file they expect to deploy. The inventory need only cover the view, export and other callers relevant to the claim. When an alias or fallback route reaches the same action, include it in that bounded map.
A controlled denial is useful only when the fixture represents the claim. Bea should be an authenticated member of B, and C7 must actually exist in A. An unauthenticated request or missing C7 could return a denial even if object permission were broken. Preserve one allowed A-member request alongside the denied B-member request to show that the intended route was exercised. These are proposed validation conditions; no requests have been performed here.
Filled conclusion for the case
- Claim reviewed: At revision h31, GET /api/cases/:caseId allows any signed-in user to read private case notes.
- Supporting observation: showCase has no inline authorization check.
- Counterevidence: The deployed router registration places authenticate, loadCase, and canReadCase before showCase. canReadCase checks the server-derived member against the loaded case's organization and denies before calling the next callback. The supplied source facts show that the foreign-organization branch returns before next(); the controlled validation described above remains a procedure to perform, not a reported result.
- Decision: Reject the claim for this GET route and deployment, subject to the recorded router and policy versions.
- Open question: The legacy export route was not covered by this review. Create a separate candidate and inspect its middleware chain and returned object.
The conclusion is narrow. If canReadCase trusts a header the requester can choose, if the export route skips it, or if production mounts a different router, the verdict changes. If route registration evidence is unavailable, the correct status is unresolved. A developer should be able to point an AppSec reviewer to exact source ranges and configuration rather than asking them to accept “middleware handles it.”
Start by drawing the chain for one disputed route using the five-row table above. For a wider investigation across callers, use the reachability guide. For the disposition of a defeated alert and any newly discovered route, use the false-positive investigation guide.