Skip to main content
BlogSecurity

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.

Matthews Wong

Software Engineer

July 20269 min read
The IDOR That Let One Branch Read Another Branch's Revenue

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.

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.

  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.

// 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');
});
LayerIts jobWhat it cannot do alone
Guard (CanActivate)Assert a branch exists on the token and pin it to the requestIt 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 branchIt trusts whatever branchId it is given, so the source must be safe
Required-argument typingFail the build when a call site forgets branchIdIt checks presence, not correctness of the value at runtime
e2e authorization testProve cross-branch access returns 404 and stays that wayIt 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

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.