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.
// 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.
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.
// 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.
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.
- Grep the codebase for every findUnique and findMany call, and list every service method that returns branch-owned data.
- Convert each detail fetch from findUnique by id to findFirst with id and branchId, returning 404 on a miss.
- Make branchId a required parameter on every service method, so the type checker rejects any call site that forgets it.
- Route every branchId through the guard-populated request, and delete any code path that read branchId from the client.
- 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.
// 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.

