# The IDOR That Let One Branch Read Another Branch's Revenue

> A real IDOR in a multi-branch ERP: how a detail endpoint that fetched by id alone leaked another branch's revenue, and the branchId-scoping fix I rolled across every Prisma module.

URL: https://www.matthewswong.com/en/blog/branchid-scoping-idor-multi-branch-erp/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-14
- Updated: 2026-07-14
- Category: Security

> **Key takeaway:** In a multi-branch ERP on shared PostgreSQL, every list and detail query must filter by branchId, not just by record id. Use Prisma findFirst with a where clause that requires both id and branchId, derive branchId from the authenticated token, and enforce it with a guard so a forgotten scope cannot leak another branch's data.

The JID Carwash ERP runs three branches on one PostgreSQL database. Orders, payments, kasbon (employee cash advances), and payroll all live in shared tables with a branchId column. That design is cheap and simple until the day you realise a cashier at one branch can read another branch's revenue by changing a number in a URL. This is the story of the insecure direct object reference (IDOR) that shipped in my code, how I found it, and the systematic fix pattern I applied across every module.

OWASP calls this Broken Object Level Authorization, ranked number one in the 2023 API Security Top 10, rated easy to exploit and widespread. The classic form is an authenticated user swapping an id for a peer's id at the same privilege level, with no escalation involved. That is exactly what my ERP allowed, and the fix touches data access, request context, and guard enforcement all at once.

## How the vulnerability surfaced

It started as a support ticket, not a pentest. A branch manager reported seeing an order in the detail view that did not match anything in their own list. The order list was correctly scoped, but the detail endpoint fetched by id alone. When I opened the service I found the smell immediately: the list query filtered by branchId, and the detail query did not. Two queries against the same table, two different authorization rules. The URL carried a sequential integer-like id, so incrementing it walked straight into another branch's orders.

I reproduced it in ten seconds with a real session token. Logged in as branch A, I called the detail endpoint with an order id that belonged to branch B and got back the full record: customer name, plate number, and the settled amount in integer IDR. No error, no empty response, just another branch's money on screen. That is the dangerous property of a missing scope. It does not throw, it quietly returns the wrong row.

## The two-query trap and the correct pattern

The root cause is that Prisma gives you two convenient ways to fetch one row, and only one of them can be scoped. findUnique takes the primary key and refuses a compound where clause on non-unique columns, so it cannot filter by branchId. findFirst accepts an arbitrary where clause, so it can require id and branchId together. The IDOR crept in because findUnique reads cleaner for a detail endpoint, and nobody notices the missing tenant filter until data leaks.

> Return 404 for a row that exists in another branch, never 403. A 403 confirms the id is real and tells an attacker exactly which ids to enumerate. Treat cross-branch access as if the record simply does not exist, because from the caller's authorization scope it does not.

## Where branchId comes from: the token, never the request

A scoped query is only as safe as the branchId you feed it. The single most important rule is that branchId comes from the authenticated JWT, never from a query parameter, request body, or header the client controls. If the client can send its own branchId, you have rebuilt the IDOR one layer up. In NestJS I attach the branch to the request during authentication, and a small decorator hands it to controllers so services always receive a trusted value.

Guards are the right layer for this. In NestJS the pipeline runs middleware, then guards, then interceptors, then the handler, so a guard that fails short-circuits the request before any service code executes. Guards implement CanActivate and are purpose-built for authorization decisions, which is exactly what branch scoping is. I kept the derived branchId on the request object rather than re-reading the token in every service, so there is one trusted source and one place to audit.

## Rolling the fix across every module

Fixing orders was not enough. The same two-query trap existed in payments, kasbon, payroll, purchases, inventory, and the derived cash book view. I treated it as a systematic audit rather than a one-off patch, because a single missed module reopens the whole vulnerability.

1. Grep the codebase for every findUnique and findMany call, and list every service method that returns branch-owned data.
2. Convert each detail fetch from findUnique by id to findFirst with id and branchId, returning 404 on a miss.
3. Make branchId a required parameter on every service method, so the type checker rejects any call site that forgets it.
4. Route every branchId through the guard-populated request, and delete any code path that read branchId from the client.
5. Write an authorization test per module that logs in as branch A and asserts a 404 for a branch B id.

> Making branchId a required argument on every service method turned a runtime security bug into a compile-time error. After the refactor, forgetting to scope a query no longer leaks data at 2am; it fails the TypeScript build on my laptop. Push the invariant into the type system whenever you can.

## Testing that the hole stays closed

A security fix without a regression test is a fix that gets undone in six months. My test seeds two branches, creates one order in each, then authenticates as branch A and asserts that requesting branch B's order id returns 404. The same shape of test runs against every scoped module. It is boring, repetitive, and it is the reason a future refactor cannot silently reintroduce the leak.

| Layer | Its job | What it cannot do alone |
| --- | --- | --- |
| Guard (CanActivate) | Assert a branch exists on the token and pin it to the request | It does not filter rows; a scoped query still has to run |
| Scoped query (findFirst id + branchId) | Return the row only when it belongs to the caller's branch | It trusts whatever branchId it is given, so the source must be safe |
| Required-argument typing | Fail the build when a call site forgets branchId | It checks presence, not correctness of the value at runtime |
| e2e authorization test | Prove cross-branch access returns 404 and stays that way | It only covers the paths you remember to write a test for |

Defense in depth is the real lesson. No single layer is trusted to be perfect: the guard supplies a safe branchId, the query enforces it, the type system catches omissions, and the test proves it end to end. For teams on PostgreSQL that want a final backstop, database row-level security using a session variable will filter rows even when application code forgets the where clause. I did not add RLS to this ERP, but on a larger tenant count it is the layer I would reach for next.

## Sources

- OWASP API Security Top 10 2023 — API1:2023 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- NestJS Documentation — Guards (CanActivate, execution order)

## Source links

- https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
- https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html
- https://docs.nestjs.com/guards

## FAQ

### What is an IDOR in a multi-branch ERP?

An IDOR, or insecure direct object reference, is a broken access control bug where an authenticated user reads a record they do not own by supplying its id. In a multi-branch ERP it means a user at one branch fetches another branch's order, payment, or payroll simply by changing the id in the URL. OWASP ranks it as Broken Object Level Authorization, number one in the 2023 API Security Top 10.

### Why use Prisma findFirst instead of findUnique for detail endpoints?

findUnique only accepts the primary key and will not filter by a non-unique column like branchId, so it cannot enforce tenant scope. findFirst accepts an arbitrary where clause, letting you require id and branchId together. Using findFirst with both fields means a request for another branch's id matches nothing and returns 404.

### Where should branchId come from to prevent IDOR?

branchId must be derived from the authenticated token, never from a query parameter, request body, or client header. If the client can send its own branchId, an attacker just supplies another branch's value and the vulnerability returns one layer up. Pin the branch onto the request in a guard and read it only from there.

### Should cross-branch access return 403 or 404?

Return 404, not 403. A 403 confirms that the id exists in some other branch, which helps an attacker enumerate valid ids. Treating the record as simply not found matches the caller's authorization scope and reveals nothing about data outside it.

### How do you stop the IDOR from coming back after the fix?

Push the invariant into the type system by making branchId a required argument on every service method, so a forgotten scope fails the build. Then add an end-to-end authorization test per module that logs in as one branch and asserts a 404 for another branch's id. For larger tenant counts, PostgreSQL row-level security adds a database backstop.

---

- HTML version: https://www.matthewswong.com/en/blog/branchid-scoping-idor-multi-branch-erp/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/branchid-scoping-idor-multi-branch-erp/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
