Preventing IDOR in REST APIs with Object-Scoped Queries

A REST API makes object references explicit: the identifier sits in the path, the client is expected to send it, and the server is expected to decide whether that particular caller may have that particular object. When the decision is missing, changing one digit is the entire exploit. This guide walks through converting a service from the fetch-then-check pattern to object-scoped queries, including the nested and bulk routes that survive the first pass of remediation, and the test that keeps the fix in place after the next feature lands.

It is part of the Access Control & IDOR Prevention guide within Vulnerability Patterns & Web Mitigation Strategies. The scoping described here assumes the caller’s identity is already trustworthy — see secure authentication and session architecture if the subject itself can be forged, because no amount of query scoping helps when the tenant identifier comes from the client.

Prerequisites

  • A REST service with session or token authentication that yields a subject with a tenant or owner identifier
  • A data access layer you can wrap: an ORM, a query builder, or a repository module
  • Test fixtures for two tenants, each with at least one record per resource type
  • A generated route list — from the OpenAPI document, the router, or a framework introspection command

Expected Outcomes

  • Every object-bearing route resolves its object with the subject in the predicate
  • Absent and forbidden objects return the same status, removing the enumeration oracle
  • Nested and batch routes scope by parent and subject rather than by primary key alone
  • A cross-tenant suite runs on every pull request and covers newly added routes automatically

Step 1: Inventory Every Route That Accepts an Identifier

Remediation without an inventory is guesswork, and the routes you forget are precisely the ones nobody thinks about. Generate the list mechanically so it stays current.

# Express: list routes and flag those taking a path parameter.
node -e '
const app = require("./src/app");
const rows = app._router.stack
  .filter((l) => l.route)
  .flatMap((l) => Object.keys(l.route.methods).map((m) => ({
    method: m.toUpperCase(),
    path: l.route.path,
    takesObjectId: /:\w*[Ii]d\b/.test(l.route.path),
  })));
console.log(JSON.stringify(rows, null, 2));
' > generated/endpoint-inventory.json

For each route with an identifier, record three things: the resource it returns, the column that expresses ownership, and whether the route is intentionally cross-tenant. The third column matters — a handful of routes legitimately serve every tenant, and naming them explicitly is what stops “it is supposed to do that” being used to dismiss a real finding later.

What the Route Inventory Has to Record Four columns per route: the method and path, the resource it returns, the column that expresses ownership for that resource, and an explicit flag for routes that are intentionally cross-tenant. Sample rows show an invoice route scoped by tenant, a nested document route scoped by project, and an administrative route flagged as deliberate. Method and path Resource Ownership column Cross-tenant? GET /api/invoices/:id invoice tenant_id no GET /projects/:pid/docs/:id document project_id + tenant no GET /admin/tenants/:id tenant staff role, audited yes, declared

Step 2: Introduce a Scoped Repository

The mechanical fix is to make the subject a required input to data access. A repository built from a subject can only produce scoped queries, so the vulnerable call has no method to invoke.

// src/data/scoped.ts
export type Subject = { userId: string; tenantId: string; role: string };

export function scopedRepo(subject: Subject) {
  const own = { tenantId: subject.tenantId };
  return {
    invoice: {
      byId: (id: string) => db.invoice.findFirst({ where: { id, ...own } }),
      update: (id: string, data: InvoicePatch) =>
        db.invoice.updateMany({ where: { id, ...own }, data }),
      remove: (id: string) => db.invoice.deleteMany({ where: { id, ...own } }),
    },
    document: {
      byIdInProject: (projectId: string, id: string) =>
        db.document.findFirst({ where: { id, projectId, project: own } }),
    },
  };
}

Handlers then read almost identically to before, minus the opportunity for error:

app.get('/api/invoices/:id', requireSession, async (req, res) => {
  const repo = scopedRepo(req.session.subject);
  const invoice = await repo.invoice.byId(req.params.id);
  if (!invoice) return res.status(404).json({ error: 'not_found' });
  res.json(serialise(invoice));
});

app.patch('/api/invoices/:id', requireSession, async (req, res) => {
  const repo = scopedRepo(req.session.subject);
  const { count } = await repo.invoice.update(req.params.id, parsePatch(req.body));
  if (count === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

Note the write path returns the affected count rather than trusting an earlier read. An update that matches zero rows is indistinguishable from an update to a non-existent record, which is exactly the response you want.

Keep the response shape uniform. not_found for both absent and forbidden objects removes the oracle described in the parent guide. If a route genuinely needs to distinguish them — a collaborator without delete rights on a document they can see — return 403 there and document the exception in the inventory.


Step 3: Handle Nested Resources and Batch Routes

Nested routes are where a first remediation pass typically stops short. The parent check passes, the child is fetched by primary key, and the vulnerability moves one level down instead of disappearing.

// VULNERABLE: parent authorised, child fetched by its own key.
const project = await repo.project.byId(req.params.projectId);   // scoped, fine
const doc = await db.document.findUnique({ where: { id: req.params.id } }); // not scoped

// SECURE: the child predicate names the parent and the tenant.
const doc = await repo.document.byIdInProject(req.params.projectId, req.params.id);

Batch endpoints need the same treatment plus a count comparison, because a partial result is itself a disclosure:

app.post('/api/invoices/bulk-void', requireSession, async (req, res) => {
  const ids: string[] = z.array(z.string().uuid()).max(200).parse(req.body.ids);
  const repo = scopedRepo(req.session.subject);

  // One scoped query — never a loop of unscoped reads.
  const owned = await db.invoice.findMany({
    where: { id: { in: ids }, tenantId: req.session.subject.tenantId },
    select: { id: true },
  });

  if (owned.length !== ids.length) {
    // Do not reveal WHICH identifiers were foreign or absent.
    return res.status(404).json({ error: 'not_found' });
  }
  await repo.invoice.voidMany(ids);
  res.status(204).end();
});
Why Partial Success Is a Disclosure Two batch handlers compared. The loop variant processes each identifier separately and returns a per-item result, so a mixed response tells the caller exactly which foreign identifiers were real. The scoped variant resolves all identifiers in one query, compares the returned count with the requested count, and returns a single uniform not-found when they differ. Per-item loop request: ids 41, 42, 43 — one of them foreign response: 41 ok, 42 forbidden, 43 not found The caller now knows 42 exists and belongs to someone else, while 43 does not exist — a working enumeration oracle, batched. Often the loop body is unscoped as well. Scoped, all-or-nothing request: ids 41, 42, 43 — one of them foreign one query returns 2 rows for 3 requested ids response: a single not-found, no per-item detail, and no partial write performed Cap the array length too, so the endpoint is not a convenient enumeration accelerator.

Verification

Run the cross-tenant probe manually first — it takes minutes and usually finds something before the automated suite is even written.

What the Manual Probe Actually Tells You Four probe outcomes against a single object route. A 404 with no canary is correct. A 200 with foreign data is a confirmed finding. A 403 for real objects and 404 for absent ones is an enumeration oracle. And a 200 with an empty envelope that still leaks a name or a total is the case a status-code-only assertion misses entirely. 404, no canary in the body — correct the scoped query matched nothing, and nothing about the object leaked 200 with the foreign record — confirmed finding the route resolves by identifier alone; fix the query, not the response 403 for real ids, 404 for absent ones — an oracle the pair of answers enumerates which identifiers exist; make them identical 200 with an empty envelope but a leaked name or total why the test asserts on a canary value, not only on the status code
# Session for tenant A; identifiers belonging to tenant B.
A_COOKIE=$(curl -s -c - -d '{"email":"[email protected]","password":"…"}' \
  -H 'Content-Type: application/json' http://localhost:3000/api/login | awk '/session/{print $7}')

for ID in $B_INVOICE_IDS; do
  code=$(curl -s -o /tmp/body -w '%{http_code}' \
    -H "Cookie: session=$A_COOKIE" "http://localhost:3000/api/invoices/$ID")
  echo "$ID -> $code $(grep -c 'tenant-b' /tmp/body) canary hits"
done
# Expected: 404 for every identifier, and zero canary hits in every body.

Then lock it in as a suite driven by the inventory, so new routes are covered without anyone remembering to extend it:

for (const ep of inventory.filter((e) => e.takesObjectId && !e.crossTenantByDesign)) {
  it(`${ep.method} ${ep.path} refuses tenant B objects`, async () => {
    const res = await call(ep, { as: tenantA, id: tenantB.ids[ep.resource] });
    expect([403, 404]).toContain(res.status);
    expect(JSON.stringify(res.body ?? '')).not.toContain(tenantB.canary);
  });
}

The canary assertion is the part that catches a handler returning 200 {"data": null, "meta": {...}} where the metadata still leaks the foreign record’s name or total.


Troubleshooting

Symptom Likely cause Fix
Cross-tenant test passes but production still leaks The leaking route is missing from the generated inventory Generate from the router at runtime, not from a hand-written list, and diff the file in CI
Scoped query returns nothing for legitimate users The subject’s tenant is resolved at login and the user switched tenants mid-session Resolve the tenant per request from the session, and re-issue the session on tenant switch
Nested route still returns foreign children The child predicate names the parent but not the tenant, and the parent identifier was itself unscoped Scope both levels; the parent lookup must also be a scoped call
Update returns 204 but changes nothing The handler ignores the affected-row count from a scoped updateMany Compare the count with the expected number and return not-found on zero
Reporting endpoints bypass the repository Analytics code connects with its own client Apply a database row-level policy, and give the reporting role its own scoped view

Common Implementation Mistakes


Frequently Asked Questions

How do I migrate an existing API without breaking clients?

Run both paths in parallel first. Introduce the scoped repository, and for each migrated resource log any request where the scoped query returns nothing while the unscoped one would have returned a row. Every log entry is either genuine cross-tenant access worth investigating or an access pattern your scope does not model yet — a support role, a shared workspace, a partner integration. When a resource logs no discrepancies across a full traffic cycle, switch it to enforcing. This turns a risky big-bang change into a measured rollout with evidence.

Does this apply to endpoints that only accept the caller’s own identifier?

Yes, and those routes are exploited constantly. A path like /api/users/{id}/settings still takes an identifier from the client, and clients send whatever they are given. The cleanest fix is to delete the parameter and derive the subject from the session, so there is nothing to tamper with. Where the parameter has to stay for interface compatibility, compare it with the session subject and return not-found on any mismatch rather than quietly serving the requested record.

What about admin endpoints that legitimately cross tenants?

Give them a distinct constructor — something like staffRepo(subject, reason) — that writes an audit entry and can only be built by a handler holding the staff role. Cross-tenant capability then lives in one auditable place, the test suite can exclude exactly those routes by name from the inventory, and a reviewer seeing staffRepo in a diff knows immediately that the change deserves attention. Never allow the ordinary repository to be constructed without a scope, because that is the shortcut every incident starts with.