What Is IDOR?
Insecure Direct Object Reference (IDOR) happens when an application exposes internal object identifiers to users without proper authorization checks.
GET /api/invoices/123 β User's invoice
GET /api/invoices/124 β Someone else's invoice (!)If changing the ID gives you access to another user's data, that's IDOR.
Why AI Creates IDOR Vulnerabilities
AI generates functional code. It creates endpoints that workβbut "working" doesn't mean "secure."
AI sees:
- Request comes in with ID
- Fetch data with that ID
- Return data
- Does this user own this resource?
- Should they be able to see it?
- What about delete/update operations?
Common IDOR Patterns
Pattern 1: Simple Object Access
AI-Generated (Vulnerable):
// GET /api/orders/:id
export async function GET(req, { params }) {
const order = await db.orders.findUnique({
where: { id: params.id }
})
return Response.json(order)
}The Attack:
# User finds their order ID: 42
curl /api/orders/42 # β Their order# User tries other IDs
curl /api/orders/1 # π± Admin's order
curl /api/orders/43 # π± Another user's order
The Fix:
export async function GET(req, { params }) {
const session = await getServerSession() const order = await db.orders.findFirst({
where: {
id: params.id,
userId: session.user.id // β Ownership check
}
})
if (!order) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(order)
}
Pattern 2: Update/Delete Without Ownership Check
AI-Generated (Vulnerable):
// DELETE /api/posts/:id
export async function DELETE(req, { params }) {
await db.posts.delete({ where: { id: params.id } })
return Response.json({ success: true })
}The Attack:
# User deletes anyone's posts
curl -X DELETE /api/posts/999The Fix:
export async function DELETE(req, { params }) {
const session = await getServerSession() const post = await db.posts.findFirst({
where: {
id: params.id,
authorId: session.user.id
}
})
if (!post) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
await db.posts.delete({ where: { id: params.id } })
return Response.json({ success: true })
}
Pattern 3: Batch Operations
AI-Generated (Vulnerable):
// POST /api/orders/export
export async function POST(req) {
const { orderIds } = await req.json() const orders = await db.orders.findMany({
where: { id: { in: orderIds } }
})
return generateCSV(orders)
}
The Attack:
// Request export of all orders
fetch('/api/orders/export', {
method: 'POST',
body: JSON.stringify({ orderIds: [1, 2, 3, 4, 5, /* ... */] })
})The Fix:
export async function POST(req) {
const session = await getServerSession()
const { orderIds } = await req.json() const orders = await db.orders.findMany({
where: {
id: { in: orderIds },
userId: session.user.id // β Filter to user's orders
}
})
return generateCSV(orders)
}
Pattern 4: File Access
AI-Generated (Vulnerable):
// GET /api/files/:filename
export async function GET(req, { params }) {
const file = await readFile(uploads/${params.filename})
return new Response(file)
}The Attack:
# User accesses others' files
curl /api/files/user1_tax_return.pdf
curl /api/files/user2_medical_records.pdfThe Fix:
export async function GET(req, { params }) {
const session = await getServerSession() // Lookup file ownership in database
const fileRecord = await db.files.findFirst({
where: {
filename: params.filename,
userId: session.user.id
}
})
if (!fileRecord) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
const file = await readFile(uploads/${params.filename})
return new Response(file)
}
Pattern 5: URL Parameter Reference
AI-Generated (Vulnerable):
// GET /api/users/:userId/settings
export async function GET(req, { params }) {
const settings = await db.userSettings.findUnique({
where: { userId: params.userId }
})
return Response.json(settings)
}The Attack:
# User views others' settings
curl /api/users/admin/settings
curl /api/users/1/settingsThe Fix:
export async function GET(req) {
const session = await getServerSession() // Use session user, not URL parameter
const settings = await db.userSettings.findUnique({
where: { userId: session.user.id }
})
return Response.json(settings)
}
IDOR Prevention Strategies
Strategy 1: Always Check Ownership
// Utility function
async function authorizeResource(resourceType, resourceId, userId) {
const resource = await db[resourceType].findFirst({
where: {
id: resourceId,
userId: userId
}
}) if (!resource) {
throw new AuthorizationError('Access denied')
}
return resource
}
// Usage
const order = await authorizeResource('orders', params.id, session.user.id)
Strategy 2: Use UUIDs Instead of Sequential IDs
// Sequential IDs are easy to guess
/api/orders/1
/api/orders/2
/api/orders/3// UUIDs are not
/api/orders/550e8400-e29b-41d4-a716-446655440000
Note: UUIDs reduce guessability but don't replace authorization checks.
Strategy 3: Row-Level Security (Supabase/PostgreSQL)
-- Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;-- Policy: Users see only their orders
CREATE POLICY "Users view own orders" ON orders
FOR SELECT USING (auth.uid() = user_id);
-- Policy: Users update only their orders
CREATE POLICY "Users update own orders" ON orders
FOR UPDATE USING (auth.uid() = user_id);
Strategy 4: Indirect References
// Instead of:
/api/orders/123// Use user-specific index:
/api/my-orders/1 // User's first order
/api/my-orders/2 // User's second order
export async function GET(req, { params }) {
const session = await getServerSession()
const orders = await db.orders.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }
})
return Response.json(orders[params.index - 1])
}
Testing for IDOR
Manual Testing Steps
- Log in as User A
- Access a resource: /api/orders/42
- Note the response
- Log in as User B
- Try accessing User A's resource: /api/orders/42
- If you get User A's data β IDOR vulnerability
Automated Testing
// Test script
const userAToken = 'token-for-user-a'
const userBToken = 'token-for-user-b'// Create resource as User A
const order = await fetch('/api/orders', {
method: 'POST',
headers: { Authorization: Bearer ${userAToken} },
body: JSON.stringify({ items: ['test'] })
}).then(r => r.json())
// Try to access as User B
const accessAttempt = await fetch(/api/orders/${order.id}, {
headers: { Authorization: Bearer ${userBToken} }
})
// Should be 404 or 403, not 200
if (accessAttempt.status === 200) {
console.error('IDOR VULNERABILITY FOUND!')
}
IDOR Checklist
FOR EVERY ENDPOINT WITH AN ID PARAMETER:
========================================
[ ] Ownership verified before read
[ ] Ownership verified before update
[ ] Ownership verified before delete
[ ] Batch operations filter by user
[ ] File access checks ownershipADDITIONAL PROTECTION:
=====================
[ ] UUIDs instead of sequential IDs
[ ] RLS enabled on database tables
[ ] Authorization utility functions
[ ] Automated IDOR testing in CI
The Bottom Line
IDOR is the "forgot to check ownership" vulnerability. AI always forgets. Every endpoint that takes an ID parameter needs an ownership check.
If the URL has an ID, verify the user should access it.