Access Control & IDOR Prevention
An insecure direct object reference is the least glamorous vulnerability class and the most reliably profitable one. There is no payload, no encoding trick and no parser confusion: the attacker authenticates as themselves, changes an identifier in a request, and the application returns somebody else’s data because it never asked whether they were entitled to it. Automated scanners rarely find these flaws, because from the outside a successful response to a valid request looks exactly like correct behaviour — only someone who knows the data model can tell that invoice 4187 belongs to another tenant.
This guide covers object-level authorization end to end: how the failure arises in ordinary code, how to scope every read and write to the calling subject, how to centralise the decision so new endpoints inherit it, and how to prove the control holds with tests that run on every pull request. It is part of Vulnerability Patterns & Web Mitigation Strategies, and it depends on the identity your authentication layer establishes — see secure authentication and session architecture for how a trustworthy subject is produced in the first place. Because the same endpoints are frequently the ones handling untrusted input, the controls here sit alongside injection attack prevention rather than replacing it.
Threat Anatomy
The mechanics are simple enough to describe in one sentence: the application uses an identifier supplied by the caller to select an object, and the authorization check either does not exist, runs against the wrong subject, or runs after the object has already been disclosed. What makes the class persistent is not difficulty but distribution — the check has to be present on every route, every nested resource, every export, every webhook replay and every background job, and a single omission is a complete bypass for that path.
The attacker’s workflow is correspondingly mundane. They create a legitimate account, exercise the product normally while recording every request, then replay those requests with identifiers taken from their own second account, from sequential guesses, or from identifiers that leaked through a shared link or an exported file. Anything that returns 200 with unfamiliar data is a finding. Anything that returns 403 for one record and 404 for another has told them which identifiers are real.
Three properties make this class worth treating as its own discipline. It is invisible to signature-based scanning, because the request is well formed and the response is a normal success. It scales trivially, because one working identifier pattern usually applies to every record in the table. And it is a confidentiality and integrity problem: the same missing predicate that lets an attacker read a record usually lets them update or delete it, since write handlers are written by the same hands as read handlers.
Applying the STRIDE framework puts object-level failures squarely in the Elevation of Privilege and Information Disclosure categories, and — because most of these endpoints also emit no distinguishing audit entry — frequently Repudiation as well.
Prerequisites & Scope
Before applying these controls, confirm the following are in place:
- A trustworthy subject. The caller’s identity comes from a verified session or token, never from a request parameter, a header the edge does not strip, or a client-supplied tenant field.
- A data model with an ownership edge. Every user-reachable table can answer “which tenant or user does this row belong to”, directly or through a join you are willing to run on every request.
- A single database access layer. Handlers reach data through a repository or ORM you can instrument, rather than each constructing its own connection.
- An endpoint inventory. A generated list of routes — from the OpenAPI document or the router — that can be diffed, so a new endpoint is visible to the gate described later.
- Test fixtures for at least two tenants. Cross-tenant probes are impossible without a second account whose data you can safely attempt to reach.
Out of scope here: authentication itself, network segmentation, and field-level redaction of an object the caller is entitled to see. Those are covered respectively in secure authentication and session architecture, defining trust boundaries and the data-classification work that belongs with your threat model.
Mitigation Architecture
The architecture that survives contact with a growing codebase has one property: the unsafe path is harder to write than the safe one. Guidance that relies on every developer remembering a check will fail at the rate developers forget, which over a year of feature work is close to certain. The four layers below each convert a category of forgetting into an error.
| Layer | What it does | What it cannot do |
|---|---|---|
| Scoped repository | Requires a subject argument to fetch anything; an unscoped read has no method to call | Protect a raw query written around it |
| Central policy module | Answers “may this subject perform this action on this object” in one reviewed place | Run itself — a handler must still call it |
| Database row-level policy | Enforces the tenant predicate even for a raw query, as long as the session variable is set | Help when a job connects with an administrative role |
| Cross-tenant test gate | Fails the build when any inventoried endpoint returns foreign data | Cover an endpoint that is not in the inventory |
No single layer is sufficient, and the combination is not redundant: the repository catches ordinary feature code, the database policy catches the raw-query escape hatch, and the gate catches the endpoint nobody thought about. The compliance value is equally practical — SOC 2 CC6.1 and OWASP ASVS V4 both ask you to demonstrate that authorization is enforced, and a passing cross-tenant suite is evidence in a form an auditor can actually read.
Step-by-Step Implementation
Step 1 — Scope the query, do not check after the fetch (ASVS V4.1.3)
The single highest-value change is to stop writing “fetch, then compare”. A fetch-then-compare handler has a window in which the object exists in memory before anyone has decided the caller may see it, and it puts the decision in a separate statement that a later refactor can move, wrap in a condition, or drop entirely.
// VULNERABLE: the identifier alone selects the row.
app.get('/api/invoices/:id', requireSession, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
if (!invoice) return res.status(404).end();
res.json(invoice); // whose invoice? nobody asked.
});
// SECURE: ownership is part of the predicate, so a foreign id matches nothing.
app.get('/api/invoices/:id', requireSession, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.session.tenantId },
});
if (!invoice) return res.status(404).end(); // same response for absent and forbidden
res.json(invoice);
});
Apply the same rule to writes, which are more often overlooked because the developer is thinking about validation rather than retrieval:
// SECURE: the update matches on both keys, so a foreign id updates zero rows.
const result = await db.invoice.updateMany({
where: { id: req.params.id, tenantId: req.session.tenantId },
data: { status: 'void' },
});
if (result.count === 0) return res.status(404).end();
Step 2 — Make the subject a required argument (ASVS V4.1.1)
Scoping every call site by hand works until someone adds a call site. Wrap data access in a repository whose methods cannot be called without a subject, so the unsafe read is not merely discouraged but unavailable.
// One place that knows how a subject narrows a query.
export function repositoryFor(subject: Subject) {
const scope = { tenantId: subject.tenantId };
return {
invoices: {
byId: (id: string) => db.invoice.findFirst({ where: { id, ...scope } }),
list: (page: Page) => db.invoice.findMany({ where: scope, ...page }),
},
};
}
// Handlers receive a scoped repository; there is no unscoped one to reach for.
app.get('/api/invoices/:id', requireSession, async (req, res) => {
const repo = repositoryFor(req.session.subject);
const invoice = await repo.invoices.byId(req.params.id);
return invoice ? res.json(invoice) : res.status(404).end();
});
The gain is reviewability. A reviewer no longer has to reason about whether a particular predicate is correct; they only have to notice that a handler bypassed the repository, which is a far easier thing to see in a diff.
Step 3 — Centralise the decision for anything beyond ownership (ASVS V4.2.1, NIST AC-3)
Ownership answers most questions but not all of them. Shared documents, role hierarchies, delegated access and support impersonation all need a real decision rather than a predicate. Put that decision in one module, express it as data where you can, and have every handler ask the same question.
# policy.py — the single place a capability is decided.
CAPABILITIES = {
("owner", "invoice"): {"read", "update", "void", "delete"},
("member", "invoice"): {"read"},
("billing", "invoice"): {"read", "update", "void"},
("support", "invoice"): {"read"}, # impersonation is audited separately
}
def may(subject, action: str, resource) -> bool:
if subject.tenant_id != resource.tenant_id:
return False # cross-tenant is never a role question
role = subject.role_in(resource.tenant_id)
return action in CAPABILITIES.get((role, resource.kind), set())
Two rules keep this module honest. The tenant comparison happens first and unconditionally, so no role can ever be a route across a tenant boundary. And the function returns a decision rather than raising or writing a response, so it can be unit-tested exhaustively over the role and action matrix without a web framework in the way.
Step 4 — Enforce the tenant predicate in the database (ASVS V4.1.5)
Application-layer scoping does not help a reporting query, an ad-hoc migration, or a background job that connects directly. A row-level policy moves the predicate into the engine, where it applies to every statement on that connection.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- The application sets the variable at the start of each transaction,
-- from the verified session — never from a request parameter.
SET LOCAL app.tenant_id = '4f1c…';
FORCE ROW LEVEL SECURITY matters: without it the table owner bypasses the policy, and the table owner is very often the role your migrations — and sometimes your application — connect as. Give the application a dedicated non-owner role, and keep the owner role for schema changes only.
Edge Cases & Bypass Patterns
Nested and sibling resources
/api/projects/12/documents/98 is two authorization questions, and the second is usually the one that is missed: the handler verifies the caller may see project 12 and then fetches document 98 by its own primary key. Scope the child query by the parent as well as the subject, so a document belonging to another project cannot be reached through a project the caller does own.
Bulk endpoints and exports
Batch operations often accept an array of identifiers and loop, and the loop body is frequently the unscoped variant of an otherwise scoped handler. Worse, a partial success response tells the attacker exactly which of the submitted identifiers exist. Scope the whole batch in one query and compare the returned count with the requested count before doing anything.
Identifiers that arrive somewhere unexpected
A scoped handler can still be bypassed when the identifier comes from a place nobody treated as user input: a signed URL parameter that is verified but never bound to a subject, a webhook replay, a message on a queue, or a “resume where you left off” cookie. Any path that turns an identifier into an object needs the same predicate.
The GraphQL field that nobody guarded
In a schema-driven API the top-level resolver is usually well protected and the nested field resolver is not, so me { organisation { members { invoices } } } walks from a legitimate root into data the caller may not see. Authorise per resolver against the parent object, not once at the query root.
Automated Testing & CI Validation
The test that matters is embarrassingly simple: authenticate as tenant A, request tenant B’s objects on every endpoint, and assert that nothing comes back. Its value comes from being exhaustive and from running on every change, which is what turns it from a one-off penetration-test finding into a control.
// cross-tenant.spec.ts — runs against every inventoried endpoint.
import { endpoints } from './generated/endpoint-inventory.json';
const A = await login('[email protected]');
const B = await seedTenantB(); // known-good identifiers owned by B
for (const ep of endpoints.filter((e) => e.takesObjectId)) {
it(`${ep.method} ${ep.path} refuses a foreign object`, async () => {
const res = await request(app)[ep.method.toLowerCase()](ep.path.replace(':id', B.ids[ep.resource]))
.set('Cookie', A.cookie)
.send(ep.sampleBody ?? {});
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(B.canaryValue);
});
}
Two details make it hold up. The canary assertion catches the handler that returns 200 with an empty envelope wrapping foreign data. And driving the loop from a generated inventory means a new endpoint is covered the day it is added, rather than the day someone remembers to extend the suite. Wire the inventory diff into the same job:
- name: Authorization coverage gate
run: |
npm run generate:endpoint-inventory
git diff --exit-code generated/endpoint-inventory.json || {
echo "::error::New endpoints detected. Add object-level tests, then commit the inventory."
exit 1
}
npx vitest run cross-tenant.spec.ts
This is the same shape as the drift gates used for attack surface mapping: the build fails not because something is known to be broken, but because something new is unproven.
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| SOC 2 | CC6.1 — logical access | Scoped repository plus row-level policy, evidenced by the cross-tenant suite |
| SOC 2 | CC6.3 — access removal | Central policy module resolving roles at request time, not at login |
| OWASP ASVS | V4.1.3 — least privilege enforcement | Ownership predicate in the query rather than a post-fetch comparison |
| OWASP ASVS | V4.2.1 — object-level authorization | Central policy consulted for every non-ownership decision |
| NIST SP 800-53 | AC-3 — access enforcement | Database row-level policy applied with a non-owner application role |
| ISO 27001 | A.8.3 — information access restriction | Endpoint inventory and passing gate retained as the periodic evidence artefact |
Common Pitfalls Checklist
Frequently Asked Questions
Do random identifiers such as UUIDs prevent IDOR?
No. An unguessable identifier raises the cost of enumeration and nothing else. Identifiers leak constantly through shared links, referrer headers, exported files, support tickets, log aggregators and analytics payloads, and every leaked identifier still returns its object because the server never asked whether the caller was entitled to it. Random identifiers are a worthwhile defence against bulk scraping and should be used, but they must never be the reason an endpoint is considered safe.
What is the difference between IDOR and broken function-level authorization?
Object-level failures let a legitimate user reach another user’s instance of a resource they are otherwise allowed to use — reading invoice 4187 instead of their own 4186. Function-level failures let a user reach an operation they should not be able to perform at all, such as an administrative route that checks only that the caller is logged in. Both belong to broken access control, but they surface under different tests: object-level needs a cross-tenant probe with a valid session, function-level needs a low-privilege caller hitting privileged routes.
Should the API return 404 or 403 for an object the caller cannot see?
Return 404 when the existence of the object is itself sensitive, which covers most multi-tenant data — a 403 confirms the identifier is real and hands an attacker a working enumeration signal. Return 403 when the caller may legitimately know the object exists but lacks a particular permission on it, such as a team member without delete rights. What matters most is consistency: a codebase that mixes the two answers has built an oracle regardless of which one it uses in any given handler.
Can an ORM or framework prevent this automatically?
Only partly, and only if you let it. A default query scope, a row-level policy, or a policy layer wired into the query builder each make the safe path the default. None of them cover a handler that drops to a raw query, a job that connects with an administrative role, or a code path that runs with no subject at all. The durable pattern is a repository that requires a subject argument, because it converts “someone forgot the check” into “there is no method to call”, which review and type-checking can both catch.
Related
- Vulnerability Patterns & Web Mitigation Strategies — the parent guide covering every web vulnerability class and its controls
- Preventing IDOR in REST APIs with Object-Scoped Queries — the hands-on implementation walkthrough for a REST service
- Row-Level Security for Multi-Tenant PostgreSQL — pushing the tenant predicate into the database engine
- Scope-Based vs Role-Based API Authorization — choosing between token scopes and server-side roles
- Secure Authentication & Session Architecture — where the trustworthy subject these controls depend on comes from