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

Photo by ninjaonelive via Openverse (CC BY 2.0)
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.
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.
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.
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.
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.

Photo by ninjaonelive via Openverse (CC BY 2.0)
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.
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 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.
// BEFORE — the IDOR. Any authenticated user reaches any branch's order.
async findOne(id: string) {
return this.prisma.order.findUnique({
where: { id }, // primary key only — branch is ignored
});
}
// AFTER — scoped detail. id AND branchId must both match, or it is 404.
async findOne(id: string, branchId: string) {
const order = await this.prisma.order.findFirst({
where: { id, branchId },
});
if (!order) {
// Return 404, not 403: never confirm the row exists in another branch.
throw new NotFoundException('Order not found');
}
return order;
}
// LIST — the same rule. branchId is a required filter, never optional.
async findAll(branchId: string, params: ListOrdersDto) {
return this.prisma.order.findMany({
where: { branchId, status: params.status },
orderBy: { createdAt: 'desc' },
});
}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.
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.
// Guard runs after auth, before the handler. It asserts a branch is present
// and pins it onto the request so no handler can read data unscoped.
@Injectable()
export class BranchScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const branchId = req.user?.branchId; // set by JwtStrategy from the token
if (!branchId) {
throw new ForbiddenException('No branch context on this session');
}
req.branchId = branchId; // the ONLY trusted source downstream
return true;
}
}
// Param decorator — controllers can only get branchId from the guarded request.
export const BranchId = createParamDecorator(
(_data, ctx: ExecutionContext): string => ctx.switchToHttp().getRequest().branchId,
);
// Controller — branchId is injected, not accepted from the client.
@UseGuards(JwtAuthGuard, BranchScopeGuard)
@Get(':id')
findOne(@Param('id') id: string, @BranchId() branchId: string) {
return this.ordersService.findOne(id, branchId);
}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.
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.
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.
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.
// e2e authorization test — the regression net for the IDOR.
it('does not leak another branch order via the detail endpoint', async () => {
const orderB = await seedOrder({ branchId: 'branch-B' });
const res = await request(app.getHttpServer())
.get(`/orders/${orderB.id}`)
.set('Authorization', `Bearer ${branchAToken}`);
expect(res.status).toBe(404); // never 200, never 403
expect(res.body).not.toHaveProperty('amount');
});| 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.