IDOR: The Vulnerability AI Can't See
AI generates CRUD endpoints that work perfectly — but don't check if the requesting user actually owns the resource. Here's how IDOR vulnerabilities slip into AI-generated code, how attackers exploit them, and how to fix every one.
The code works. The code is also broken.
You prompt your AI assistant: "Create a Next.js API route that fetches an invoice by ID." Thirty seconds later, you have a clean endpoint. It handles errors. It returns JSON. It types correctly. You test it, and it works.
There's just one problem: it returns anyone's invoice to anyone who asks.
This is IDOR — Insecure Direct Object Reference — and it is the single most common vulnerability in AI-generated code. Not because the AI is bad at coding. Because the AI is too good at giving you exactly what you asked for, and you didn't ask for authorization.
What IDOR actually is
IDOR happens when an application uses a user-supplied identifier (like an ID in a URL) to fetch a resource, without verifying that the requesting user is allowed to access it.
The name says it all: you're directly referencing an object (a database row, a file, a record) using an identifier the user controls. If the server doesn't check ownership, any user can access any object just by changing the ID.
IDOR falls under OWASP A01: Broken Access Control — the number one web application vulnerability category. It's been at the top of the OWASP Top 10 since 2021, and it's there for a reason: access control is hard to automate, easy to forget, and invisible when it's missing.
A page that loads the wrong user's data looks identical to a page that loads the right user's data. The UI doesn't break. The tests pass. The demo works. The vulnerability is silent until someone exploits it.
Why AI-generated code is especially prone to IDOR
When you ask an AI to build a CRUD API, it focuses on the functionality you described: create, read, update, delete. It generates code that does exactly that. The problem is what it doesn't generate.
Here's the pattern. You ask for an API route. The AI produces:
- Parse the ID from the URL
- Query the database for that ID
- Return the result
What's missing is step 1.5: verify that the authenticated user owns or has permission to access this resource. The AI skips this step because you didn't ask for it, and because authorization logic requires understanding your specific data model — who owns what, who can see what, what roles exist. The AI doesn't know your business rules. It just knows you want an invoice by ID.
This creates a dangerous situation: code that is functionally correct but fundamentally insecure. It works in every test you run because you're testing with your own data. The vulnerability only manifests when a different user tries to access your resources — and you never test for that.
The attack: three AI-generated endpoints, three exploits
Let's walk through three endpoints that an AI would typically generate for a Next.js application. Each one works perfectly. Each one is exploitable.
1. GET /api/invoices/[id] — Reading anyone's invoice
Here's what the AI generates when you ask for an invoice API:
// app/api/invoices/[id]/route.ts — VULNERABLE
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const invoice = await db.invoice.findUnique({
where: { id: params.id },
});
if (!invoice) {
return NextResponse.json(
{ error: "Invoice not found" },
{ status: 404 }
);
}
return NextResponse.json(invoice);
}
Clean code. Good error handling. Completely insecure.
The attack: User A is logged in and views their invoice at /api/invoices/inv_abc123. They notice the ID in the URL, change it to inv_def456, and now they're looking at User B's invoice — complete with billing address, line items, and payment details.
# User A's legitimate request
curl -H "Authorization: Bearer <user_a_token>" \
https://app.example.com/api/invoices/inv_abc123
# Returns User A's invoice ✓
# User A changes the ID — gets User B's invoice
curl -H "Authorization: Bearer <user_a_token>" \
https://app.example.com/api/invoices/inv_def456
# Returns User B's invoice ✗ — this should be 403
The fix:
// app/api/invoices/[id]/route.ts — FIXED
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await getSession(request);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const invoice = await db.invoice.findUnique({
where: {
id: params.id,
userId: session.user.id, // Ownership check
},
});
if (!invoice) {
return NextResponse.json(
{ error: "Invoice not found" },
{ status: 404 }
);
}
return NextResponse.json(invoice);
}
The fix is one line in the query: userId: session.user.id. The database now only returns the invoice if it belongs to the requesting user. If someone tries another user's ID, they get a 404 — they can't even confirm the invoice exists.
2. PUT /api/users/[id] — Modifying anyone's profile
// app/api/users/[id]/route.ts — VULNERABLE
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json();
const user = await db.user.update({
where: { id: params.id },
data: {
name: body.name,
email: body.email,
bio: body.bio,
},
});
return NextResponse.json(user);
}
The attack: User A sends a PUT request to /api/users/user_b_id with a new email address. Now User B's account has User A's email, and User A can trigger a password reset to take over the account entirely.
# User A modifies User B's profile
curl -X PUT -H "Authorization: Bearer <user_a_token>" \
-H "Content-Type: application/json" \
-d '{"email": "attacker@evil.com"}' \
https://app.example.com/api/users/user_b_id
# User B's email is now changed — account takeover incoming
The fix:
// app/api/users/[id]/route.ts — FIXED
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await getSession(request);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Users can only update their own profile
if (params.id !== session.user.id) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
const user = await db.user.update({
where: { id: session.user.id }, // Use session ID, not URL param
data: {
name: body.name,
bio: body.bio,
// Note: email changes should require re-verification
},
});
return NextResponse.json(user);
}
Two key changes: an explicit ownership check (params.id !== session.user.id), and using the session ID for the database query instead of the URL parameter. Even if the check were somehow bypassed, the query itself is anchored to the authenticated user.
3. DELETE /api/posts/[id] — Deleting anyone's post
// app/api/posts/[id]/route.ts — VULNERABLE
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.post.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
}
The attack: This one is destructive. User A enumerates post IDs (often sequential or predictable) and deletes every post in the application. No ownership check, no authentication check, no rate limiting.
# Delete every post by iterating through IDs
for id in $(seq 1 1000); do
curl -X DELETE https://app.example.com/api/posts/$id
done
# Every post in the database is now gone
The fix:
// app/api/posts/[id]/route.ts — FIXED
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await getSession(request);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Only delete if the post belongs to the authenticated user
const deleted = await db.post.deleteMany({
where: {
id: params.id,
authorId: session.user.id, // Ownership check
},
});
if (deleted.count === 0) {
return NextResponse.json(
{ error: "Post not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
}
Using deleteMany with both the post ID and the author ID means the operation silently does nothing if the post doesn't belong to the user. The attacker gets a 404 and can't even confirm whether the post exists.
Horizontal vs. vertical privilege escalation
The examples above are all horizontal privilege escalation — a regular user accessing another regular user's data. Same privilege level, different data.
Vertical privilege escalation is when a regular user accesses admin-level functionality. IDOR enables this too:
// What if user roles are stored in the users table?
// VULNERABLE: User changes their own role
const user = await db.user.update({
where: { id: params.id },
data: body, // If body contains { role: "admin" }...
});
An attacker sends { "role": "admin" } in the request body. If the endpoint doesn't filter which fields can be updated, the user just promoted themselves. This is IDOR combined with mass assignment — another pattern AI frequently generates.
The defense against vertical escalation is the same principle, applied more broadly: never trust the client to tell you what they're allowed to do.
// FIXED: Whitelist allowed fields
const { name, bio } = body; // Only extract safe fields
const user = await db.user.update({
where: { id: session.user.id },
data: { name, bio }, // role, email, isAdmin are never writable
});
The pattern behind the pattern
Every IDOR vulnerability follows the same structure:
- User input controls a resource identifier (URL param, query string, request body)
- The server uses that identifier to fetch/modify/delete a resource
- No check verifies the requesting user's relationship to that resource
The fix is also always the same structure:
- Authenticate the user (who is making this request?)
- Determine ownership or permission (do they have the right to this resource?)
- Scope the database query (include the user ID in the WHERE clause)
This is why IDOR is hard for AI to catch: it requires understanding the relationship between the authenticated user and the requested resource. The AI generates the query (step 2 of the vulnerability) correctly every time. It just never generates the authorization check (step 2 of the fix).
How to find IDORs in your own app
If you've built an app with AI assistance, here's a practical audit checklist:
1. Map every endpoint that takes an ID parameter
Search your codebase for dynamic route segments:
# In a Next.js project, find all dynamic API routes
find app/api -name "[*" -type d
# or search for params usage
grep -r "params\." app/api/ --include="*.ts"
Every file in a [id] or [slug] directory is a potential IDOR target.
2. Check each one for an ownership filter
For every endpoint you found, answer these questions:
- Does it read the session/token to identify the current user?
- Does the database query include the current user's ID?
- If it's an admin route, does it verify the user's role?
If the answer to any of these is "no," you have a potential IDOR.
3. Test with two accounts
The definitive IDOR test is simple:
- Create two user accounts (User A and User B)
- As User A, create a resource and note its ID
- As User B, try to access that resource using User A's ID
- If User B can see/modify/delete User A's resource, you have an IDOR
# Log in as User A, create an invoice
TOKEN_A=$(curl -s -X POST .../auth/login \
-d '{"email":"a@test.com","password":"pass"}' | jq -r '.token')
INVOICE_ID=$(curl -s -H "Authorization: Bearer $TOKEN_A" \
-X POST .../api/invoices \
-d '{"amount": 100}' | jq -r '.id')
# Log in as User B, try to access User A's invoice
TOKEN_B=$(curl -s -X POST .../auth/login \
-d '{"email":"b@test.com","password":"pass"}' | jq -r '.token')
curl -H "Authorization: Bearer $TOKEN_B" \
.../api/invoices/$INVOICE_ID
# If this returns the invoice → IDOR confirmed
4. Check for predictable IDs
If your app uses sequential integers (1, 2, 3...) instead of UUIDs, enumeration is trivial. Switching to UUIDs doesn't fix IDOR — you still need ownership checks — but it makes brute-force enumeration impractical.
5. Review middleware and shared utilities
In Next.js apps, check whether you have a centralized auth utility that every route uses, or whether each route handles auth independently. Inconsistent auth patterns are where IDORs hide.
// Good: centralized auth middleware
// lib/auth.ts
export async function requireAuth(request: Request) {
const session = await getSession(request);
if (!session) throw new AuthError("Unauthorized");
return session;
}
// Every route uses it
export async function GET(request: Request, { params }) {
const session = await requireAuth(request);
// ...
}
The bigger picture
IDOR is uniquely dangerous in AI-generated applications because it sits in the gap between "does this code work?" and "is this code safe?" AI passes the first test with flying colors every time. The second test requires a fundamentally different kind of thinking — adversarial thinking, not functional thinking.
When you prompt an AI to build a feature, you're describing what the application should do. Security is about what the application should prevent. Those are two different conversations, and most prompts only have the first one.
This isn't a reason to stop building with AI. It's a reason to add one more step to your workflow. After the AI generates your endpoints, ask yourself: "What happens if someone changes the ID?" If the answer is "they see someone else's data," you have an IDOR to fix.
How Flowpatrol helps
This is exactly the kind of vulnerability Flowpatrol is built to detect. Our LLM-powered scanner tests your endpoints with multiple authenticated users, automatically checking whether User A can access User B's resources. We test every dynamic route for ownership enforcement — the same two-account test described above, but automated across your entire app.
Five minutes to scan. One report with every IDOR flagged. Fix the ownership checks, ship with confidence.
IDOR is classified under OWASP A01:2021 — Broken Access Control, the number one web application security risk. For more on securing AI-generated applications, see our guides on Supabase RLS and the Top 10 Vulnerabilities in Vibe-Coded Apps.