- Security
- OWASP
- Access Control
IDOR and Broken Access Control: A Practical Guide
How IDOR and broken access control creep into REST, GraphQL and Next.js route handlers, how to test for them, and the fixes that hold up in real codebases.
· 7 min read · Lina Source LLC
Broken access control is the bug class that survives every framework upgrade. Your ORM escapes SQL, your template engine escapes HTML, but nothing in the stack knows that invoice 4812 belongs to Alice and not to Bob. That knowledge lives in your code, and when one handler forgets to apply it, any logged-in user can read or change someone else's data.
The most common form is the insecure direct object reference, or IDOR: the client sends an identifier, the server loads the record with that identifier, and nobody checks whether the caller is allowed to see it. It needs no special tooling to exploit. A browser, a second account and a changed number in the URL are enough. Scanners that look for dangerous function calls rarely catch it, because the vulnerable code contains nothing dangerous: a perfectly ordinary database lookup is simply missing one condition.
The three CWEs you will see
- CWE-639, authorization bypass through user-controlled key: the classic IDOR. The record is selected by an ID the attacker controls, and ownership is never checked.
- CWE-862, missing authorization: the handler performs no authorization check at all. Often an admin or internal endpoint that was assumed to be unreachable.
- CWE-285, improper authorization: a check exists but is wrong. It checks the wrong field, checks read permission on a write, or trusts a role sent by the client.
The distinction matters when you fix the bug. A missing check means adding one; an improper check means the model of who can do what is wrong, and the same mistake is probably repeated elsewhere. When you find either kind, search for its siblings before you close the ticket. Access-control bugs are rarely one-offs; they follow the patterns a team copies from handler to handler.
How it happens in a Next.js route handler
Here is the pattern in its most common shape. The handler authenticates the user, which feels like security, and then loads the record by its ID alone. The session check answers who is calling; nothing answers whether this caller may see this invoice.
// app/api/invoices/[id]/route.ts (vulnerable)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// Any signed-in user can read any invoice by changing the ID
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}The fix is to make ownership part of the query itself, not a separate step that can be forgotten. If the record does not belong to the caller, the database returns nothing and the handler answers 404.
// app/api/invoices/[id]/route.ts (fixed)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}Returning 404 rather than 403 is deliberate. A 403 confirms that the record exists, which lets an attacker enumerate valid IDs even when they cannot read them. The same applies to timing and error messages: the response for someone else's record should be indistinguishable from the response for a record that never existed.
REST: the endpoints people forget
Teams usually protect the obvious GET by ID. The bugs hide in the other verbs and in the edges of the API:
- PATCH and DELETE handlers that were copied from the GET handler before the ownership check was added.
- Nested routes such as /projects/:projectId/tasks/:taskId, where the project is checked but the task is loaded by taskId alone and may belong to a different project.
- Bulk endpoints that accept an array of IDs and check only the first one.
- File downloads and export jobs, which often run through a separate service with its own, weaker checks.
- Update payloads that accept ownerId, organizationId or role from the request body and write them straight to the database (mass assignment).
GraphQL makes the surface wider
In GraphQL, the same object can be reached through many paths. A query-level check on invoice(id) does not help if the same invoice is also reachable through customer { invoices }, a node(id) lookup, or a mutation's return type. Every resolver that returns an object is an entry point. Batching layers such as DataLoader add another trap: a loader keyed only by ID will happily return records for any viewer, and its cache can serve one user's data to a later request if it is shared across requests.
The reliable approach is to authorize in the data layer the resolvers call, not in the resolvers themselves. If every path to an invoice goes through one function that takes the viewer and scopes the query, adding a new field or relationship cannot bypass it. Also check mutation inputs: a field like ownerId in an input type is an invitation to reassign records. Finally, remember that introspection and error messages reveal your schema, so assume attackers know every field and relationship you expose.
The same bug in Python
The shape is identical in FastAPI with SQLAlchemy. The vulnerable version calls db.get(Document, doc_id); the fixed version filters by owner in the same statement.
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# Vulnerable: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return docFixes that hold up
Scope every query by owner or tenant
Put the user or organization ID in the WHERE clause of every read and write. This turns authorization into a property of the query, which is easy to see in review. For multi-tenant apps, Postgres row-level security can enforce the tenant boundary as a second layer, so a forgotten filter returns nothing instead of another customer's rows. The same scoping applies to writes. An update should be a single statement filtered by both ID and owner, such as updateMany with both conditions followed by a check that exactly one row changed, rather than a read, a check and a separate write that can race.
Centralize the decision
Scattered if statements drift apart. A small set of helpers, one per resource, keeps the rule in one place and makes a handler without a helper call stand out.
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// Deny by default: no membership or no permission looks the same
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Deny by default
Unknown roles, missing memberships and unexpected actions should all fall through to a denial. In frameworks with middleware, require authentication for everything and mark public routes explicitly, rather than the other way round. A new route should be locked until someone decides otherwise. Never take the role, tenant or user ID from the request body or a client-set header; derive them from the verified session on the server every time.
Random identifiers such as UUIDs are worth using, but they are not a fix. IDs leak through URLs, logs, shared links and referrer headers. Treat them as unguessable only in the sense that they slow down enumeration, never as the access check.
How to test for it
IDOR testing is simple and repetitive, which is why it is worth automating once you have done it by hand. Start from an inventory: list every route, resolver and background job that accepts an identifier, including IDs hidden in request bodies, query strings and headers.
- Create two accounts, A and B, ideally in two separate organizations. Create a record as A and note its ID.
- Replay every request that references that ID with B's session: GET, PATCH, DELETE, downloads, exports and any GraphQL query or mutation that touches it.
- Expect 404 for all of them. Any 200, and any 403 that confirms existence, is a finding.
- Turn the manual check into an integration test per resource, so a new handler without a scoped query fails in CI.
- Grep for lookups by primary key alone, such as findUnique({ where: { id } }) or db.get(Model, id), and justify each one.
Code review catches what tests miss, because the missing check is visible in the source even when nobody wrote a test for that route. CodeAuditAgent reads a public GitHub repository or a pasted snippet and reports access-control gaps with the CWE, the quoted line, an exploit scenario and a proposed patch, which is a quick way to get a second pass over every handler at once.
A short checklist
- Every query that takes a client-supplied ID also filters by the caller's user or tenant.
- Authorization lives in shared helpers or the data layer, not in copy-pasted if statements.
- Unknown cases deny; public routes are the explicit exception.
- Write operations are checked as carefully as reads, including bulk and nested routes.
- Two-account tests exist for each resource and run in CI.