IDOR is the shortest path from “the user is logged in” to “the attacker can read another customer's data.” The fix is not abstract security theory. It is a consistent rule: every record fetch, update, and delete must enforce ownership or tenant scope at the data boundary itself.
The reason IDOR keeps showing up in startup apps is simple: CRUD code is easy to scaffold, and authorization is easy to postpone. A route works in development the moment it can fetch a record by id. It only becomes a breach when someone asks what happens if the id belongs to another account.
This is why broken access control stays at the top of the OWASP list. Teams often think they have “auth” because there is a session token or a Clerk userId. But auth only answers who the user is. Authorization answers whether this user is allowed to touch this resource. That second question is where the incident lives.
What IDOR Looks Like in Startup Code
Most IDOR bugs are visually boring. There is no fancy exploit chain. The route accepts an id, the database returns the matching row, and the response serializes it back to the caller. If the caller is authenticated, the code feels correct. It is only wrong because the resource itself is not scoped to the caller.
In SaaS products, the vulnerable surfaces are predictable: document views, invoices, reports, support tickets, file downloads, team settings, billing objects, invite links, export endpoints, and admin tools copied from internal use into the customer product. If the identifier can be guessed, logged, shared, or enumerated, the route needs explicit access control.
UUIDs help with unpredictability. They do not solve authorization. A UUID is still an object reference. If your code treats possession of the id as permission, the route is still insecure.
Where SaaS Teams Most Often Hide IDOR
These are the feature types that deserve immediate review in almost every startup codebase.
Document and report endpoints
Anything under /api/reports/[id], /files/[id], or /documents/[id] is high risk because the entire resource is chosen by a caller-controlled parameter.
Team settings and invites
A route that updates a team by id without verifying team membership becomes cross-customer account control, not just read leakage.
Billing and subscription objects
Customer ids, invoice ids, and checkout session references often get passed around in dashboards and admin views with weaker auth assumptions.
Background export downloads
Signed URLs and download jobs sometimes validate only that the job exists, not that the requester belongs to the job owner or tenant.
Support or admin tools reused in prod
Internal tools are often built with implicit trust. When those code paths get exposed to customer-facing workflows, the authz gap comes with them.
The Exact Pattern to Delete From Your Codebase
Never fetch or mutate a customer object by id alone if the caller is not a fully trusted internal service.
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
const { userId } = await auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
await db.update(projects)
.set(body)
.where(eq(projects.id, params.id));
return Response.json({ ok: true });
}export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
const { userId } = await auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const membership = await db.query.memberships.findFirst({
where: eq(memberships.userId, userId),
});
if (!membership) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
await db.update(projects)
.set(await ProjectUpdateSchema.parseAsync(await req.json()))
.where(and(
eq(projects.id, params.id),
eq(projects.tenantId, membership.tenantId)
));
return Response.json({ ok: true });
}The fix is stronger than “if project.ownerId === userId.” In multi-tenant systems, the correct boundary is often team or tenant membership, not just a single owner field.
If the access rule is complex, centralize it in a helper or repository layer. The goal is to make the safe query the easiest query.
How to Prevent IDOR Systematically
Scope every query by tenant or owner
Query DesignDo not perform a wide read and then filter in application memory. That leaks through timing, logging, and race conditions even when the final response is blocked.
Separate admin-only paths from customer paths
PrivilegeIf a route is allowed to see any tenant's record, it should live behind an explicit admin guard and not be reused casually in customer features.
Test with cross-account fixtures
TestingEvery feature test should include “user A tries to access user B's resource” for reads, writes, deletes, downloads, and exports.
Do not rely on UUID secrecy
DesignUnpredictable ids reduce casual abuse but do not replace authorization logic. Treat ids as routing hints, not permissions.
Scan for routes touching params.id without access checks
AutomationThis is where static analysis helps. The pattern appears repeatedly across handlers and action code when teams move fast.