Skip to main content

IDOR prevention in Express and NestJS: ownership checks that hold

· 9 min read
Vulkro
Security research

IDOR prevention comes down to one question asked on every request: is this record yours? An insecure direct object reference (IDOR) is what happens when the server asks only whether you are logged in. You request invoice 8812, the server checks your session, loads invoice 8812 and returns it. Whose invoice it was never came up.

OWASP ranks this first in the API Security Top 10 (2023) as broken object level authorization (BOLA), and its prevention advice is blunt: check that the logged-in user may act on the record every time client input picks the record. We covered the breaches in broken access control is still number one. This post is about the code: how to make that check structural in Express and NestJS with Prisma, and how to test that it stays there.

What does an IDOR look like in Express?​

router.get('/invoices/:id', requireAuth, async (req, res) => {
const invoice = await prisma.invoice.findUnique({ where: { id: req.params.id } });
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});

Nothing here is unsafe in the injection sense. The query is parameterized. requireAuth runs. The bug is a predicate that is not there: nothing ties invoice.ownerId to req.user.id.

How do you prevent IDOR in Express?​

Put the caller into the query. Not as a check after the load, but as part of the lookup, so a record that is not yours simply does not exist from your point of view:

router.get('/invoices/:id', requireAuth, async (req, res) => {
const invoice = await prisma.invoice.findFirst({
where: { id: req.params.id, ownerId: req.user.id },
});
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});

findFirst accepts non-unique fields in where, so the owner filter sits next to the id. Return 404 rather than 403: a 403 confirms that record 8812 exists.

Writes need the same treatment, and they are where people forget:

router.patch('/invoices/:id', requireAuth, async (req, res) => {
const { count } = await prisma.invoice.updateMany({
where: { id: req.params.id, ownerId: req.user.id },
data: { memo: String(req.body.memo ?? '') },
});
if (count === 0) return res.sendStatus(404);
res.sendStatus(204);
});

updateMany and deleteMany take any filter and report how many rows they touched, so a zero count means "not yours or not there". Note the data object names its one field. Passing req.body straight in is the mass-assignment bug (API3), which would let the caller rewrite ownerId itself.

In a multi-tenant app the predicate is the tenant, not the user, and the same rule applies to every query. The missing tenant column covers that version.

Do NestJS guards prevent IDOR?​

Partly. A NestJS guard decides whether a request may reach a route: is there a valid session, does the caller hold the admin role. It runs before the handler and usually before anyone has loaded the record, so it cannot know whose record :id is. A controller like this passes every guard and still leaks:

@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}

@Get(':id')
findOne(@Param('id') id: string) {
return this.invoices.findById(id); // any signed-in user, any invoice
}
}

The reliable fix is the same as in Express: pass the caller into the service and let the query scope the record.

export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);

@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) {
const invoice = await this.invoices.findForOwner(id, user.id);
if (!invoice) throw new NotFoundException();
return invoice;
}

// invoices.service.ts
findForOwner(id: string, ownerId: string) {
return this.prisma.invoice.findFirst({ where: { id, ownerId } });
}

You can write an ownership guard that loads the record and compares owners. It works, but it queries twice, it has to read the same :id the handler reads, and it drifts the day someone adds a route with a different parameter name. Scoping inside the service is harder to forget, because the only way to load an invoice is to say whose it is. If every service method takes an ownerId or tenantId argument, a missing one is a compile error rather than a breach.

The variations that slip through​

  • Nested resources. GET /projects/:projectId/tasks/:taskId checks that you belong to the project, then loads the task by taskId alone. Any task in any project is now readable by anyone who belongs to some project. Filter the task by both ids.
  • List endpoints with filters. GET /invoices?customerId=42 scoped by the query parameter instead of the session.
  • Bulk operations. POST /invoices/bulk-delete with an array of ids, checked against the first one only.
  • Ids in the body. PUT /settings with { "accountId": 7 }. The id does not have to be in the URL to be caller-controlled.
  • Exports, downloads and signed URLs. File routes are often written separately from the API and skip its conventions.

Random ids (UUIDs) make guessing harder, and OWASP lists them as a measure, but they leak through logs, emails, shared links and other endpoints. They slow an attacker down. They are not an ownership check.

Tests that catch IDOR​

Most test suites log in as one user and test that user's records, which is exactly the case that never fails. Add a second user and assert the cross-access is refused:

it("does not return another user's invoice", async () => {
const alice = await signIn('alice');
const bob = await signIn('bob');
const invoice = await createInvoice(alice);

await request(app)
.get(`/invoices/${invoice.id}`)
.set('Authorization', `Bearer ${bob.token}`)
.expect(404);

await request(app)
.patch(`/invoices/${invoice.id}`)
.set('Authorization', `Bearer ${bob.token}`)
.send({ memo: 'changed' })
.expect(404);
});

One such test per resource, covering read, update and delete, catches the whole class going forward. OWASP's advice is to treat a failure here as a blocker for deployment. The OWASP IDOR prevention cheat sheet has more patterns.

What Vulkro checks​

Vulkro reads each route's entry point with its guards (Express middleware, NestJS controller, global and opt-out guards, and the other frameworks it supports), then decides three things: is the caller authenticated, is a role required, and is the record scoped to the caller.

When a route is authenticated, requires no role, and loads or changes a record by a caller-supplied id without passing anything from the caller's identity into the lookup, vulkro scan reports idor-authn-no-ownership. Passing req.user.id or a tenant id from the session into the service call counts as scoping; a lookup by req.params.id alone does not.

This finding is Medium and marked Not checked. That is deliberate. Whether a record should be visible to every signed-in user is a business rule (a public product catalogue is fine; a medical record is not), and source code does not always say which. Vulkro reports the missing predicate for a person to decide rather than claiming a proven bug. Related checks cover the cases it can be firmer about:

  • AUTHZ-001: a request value that reaches a cross-tenant lookup through helper functions.
  • MT-001: a tenant id read from the request body instead of the session.
  • authz-role-change-no-role-check: an authenticated handler writes a role or privilege field from caller input with no role check.
  • nextjs-server-action-no-authz: a server action reads or writes by its own arguments with no authorization, covered in Next.js server actions are public endpoints.

Every finding names the route, the lookup and the check that is missing. The local console and the VS Code extension join findings into attack paths, so you can see which unscoped lookups an anonymous or any-signed-in caller can actually reach.

IDOR prevention checklist​

  1. Every lookup by a caller-supplied id includes the owner or tenant in the same query.
  2. Writes and deletes are scoped the same way, and check the affected count.
  3. Nested resources are filtered by every id in the path.
  4. The caller's identity comes from the session, never the request body or query.
  5. Not-yours returns 404.
  6. Each resource has a two-user test for read, update and delete.

Frequently asked questions​

What is the difference between IDOR and BOLA?​

They are the same flaw under two names. IDOR is the older web term; OWASP's API Security Top 10 calls it broken object level authorization (BOLA). Both mean the server returns or changes a record without checking the caller may access it.

Are UUIDs enough to prevent IDOR?​

No. They make ids hard to guess, but ids leak through links, logs and other API responses. Treat them as a speed bump and keep the ownership check in the query.

Should I put the ownership check in a NestJS guard or in the service?​

In the service, as part of the query, is the hardest to forget. A guard runs before the record is loaded and is better suited to authentication and roles. An ownership guard can work but has to duplicate the lookup and stay in sync with every route's parameters.

Can a scanner find every IDOR?​

No. A scanner can find lookups by a caller-supplied id that are not tied to the caller, which is most of them. Whether a particular record should be shared is a business decision, which is why Vulkro marks these findings Not checked instead of Proven.

Where to see it​

The API security use case shows how Vulkro lists every route with the guards in front of it. The API1 rule page and attack paths docs go deeper. To run it on your own Express or NestJS app, start here.