IDOR: the sequential-ID bug in Cursor, Bolt & Lovable apps
If a URL in your app looks like /orders/1042, change the number and see what loads. If it is someone else's order, this is your bug.
Why this is so common in AI-generated code
Ask your AI coding tool for an order-detail page or a user-profile route, and the fastest working path is almost always: read the ID from the URL, fetch that record, render it. It runs. It demos perfectly for the one account you tested with. Nothing in the generated code checks whether the record actually belongs to whoever is asking — because that check isn't necessary to make the feature appear to work, only to make it safe.
/orders/1042 fetches order 1042 and returns it — for any logged-in user, or sometimes for no user at all. Change the number, get a different order.
The route fetches order 1042, then checks order.owner_id === user.id before returning anything — backed by a Row Level Security policy that enforces the same rule at the database layer.
UUIDs are not the fix
Switching from sequential integers to UUIDs is a common first instinct, and it does help — it stops someone from casually incrementing a number and stumbling into another user's data. But it does not fix the underlying bug: there is still no check confirming ownership. If that UUID ever appears anywhere else — a shared link, a client-side API response that lists other users' records, a support screenshot — the same access problem exists, just with a longer number attached to it.
The actual fix
Two layers, not one. An explicit ownership check in the route handler, and a Row Level Security policy that enforces the same rule at the database level as a backstop:
// app/api/orders/[id]/route.ts
// Vulnerable — fetches by ID with no ownership check
export async function GET(req: Request, { params }: { params: { id: string } }) {
const { data } = await supabase.from('orders').select('*').eq('id', params.id).single();
return Response.json(data);
}
// Fixed — verifies the caller owns the record before returning it
export async function GET(req: Request, { params }: { params: { id: string } }) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const { data: order } = await supabase.from('orders').select('*').eq('id', params.id).single();
if (!order || order.owner_id !== user.id) {
return new Response('Not found', { status: 404 }); // 404, not 403 — don't confirm the record exists
}
return Response.json(order);
}Pair this with a Supabase RLS policy scoped to auth.uid() = owner_id on the same table — see the RLS guide for the exact policy syntax. The route-level check and the RLS policy should say the same thing in two places; if your application code ever has a bug, RLS is what stops the leak anyway.
Want to know if your API routes actually check ownership?
StackSecured tests your live routes for exactly this pattern — not just whether RLS is enabled, but whether an authenticated user can reach another user's data.
Run a free scanCommon questions
What is IDOR and why does it show up so often in AI-generated apps?
+
IDOR — Insecure Direct Object Reference — is what happens when an API route trusts an ID in the URL or request body without checking whether the logged-in user actually owns that record. AI coding tools like Cursor, Bolt, and Lovable are optimizing for "does the feature work when I click it," and a route that fetches /orders/:id and returns whatever it finds passes that test. It just also returns everyone else's orders if you change the number.
How do I tell if my app's URLs are vulnerable?
+
Look at how your app references user-specific records. If a URL or API call looks like /orders/1042, /api/profile/87, or /invoices/205 — a small integer that increments by one per record — you can usually view the next record by changing the number by one, with no error, no matter who is logged in. That is the fingerprint. UUIDs alone do not fix this; they just make the ID harder to guess. The actual bug is the missing ownership check on the server.
Does switching from sequential IDs to UUIDs fix the problem?
+
No — it reduces the odds of someone stumbling onto another user's ID by accident, but it does nothing if that other UUID leaks anywhere: a shared link, a browser history, a support ticket, a client-side API response that includes other users' records. The real fix is a server-side check that the record's owner matches the requesting user, on every read, update, and delete — not just making the ID longer.
How do I fix an IDOR vulnerability in a Next.js + Supabase API route?
+
Two layers, and you want both. First, Supabase Row Level Security policies scoped to auth.uid() so the database itself refuses to return rows the caller does not own, even if your application code has a bug. Second, an explicit ownership check in the route handler itself before you act on the record, so the failure is visible and testable rather than silently relying on RLS alone.
More guides: Supabase RLS: the #1 vibe-coding bug · Exposed API keys: which ones actually matter · Webhook signature verification · Full vibe-coding security checklist