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.
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();
});
Verification
Run the cross-tenant probe manually first — it takes minutes and usually finds something before the automated suite is even written.
# 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.
Related
- Access Control & IDOR Prevention — the parent guide covering the full control set and its compliance mapping
- Row-Level Security for Multi-Tenant PostgreSQL — enforcing the same predicate inside the database
- Scope-Based vs Role-Based API Authorization — deciding what a token may assert about permissions
- Parameterized Queries for SQL and NoSQL Injection — keeping the same query layer safe from injection