Common IDOR failure modes and how to prevent them
Insecure Direct Object Reference (IDOR) is consistently in the OWASP Top 10, yet it remains one of the most common vulnerabilities I find during security reviews. Here's why it happens and how to build systems that resist it.
What Is IDOR?
IDOR occurs when an application exposes a reference to an
internal object (like a database ID) and doesn't verify the
user is authorized to access that object. Change /api/invoices/123 to /api/invoices/124, and suddenly you're
viewing someone else's invoice.
Common Failure Modes
1. Trusting the Authenticated User
Authentication ≠ Authorization. Just because someone logged in doesn't mean they should see every record in your database.
// VULNERABLE: Checks auth, but not ownership
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
res.json(order); // Anyone can view any order!
}); 2. Client-Side Only Filtering
Filtering data on the frontend provides no security. Attackers bypass UIs entirely.
// VULNERABLE: Backend returns all data, frontend filters
app.get('/api/documents', requireAuth, async (req, res) => {
const allDocs = await db.documents.findAll();
res.json(allDocs); // Sends everything, "trusts" frontend
}); 3. Predictable IDs
Sequential integer IDs make enumeration trivial. An attacker just increments until they find records.
4. Missing Checks on Related Objects
You verify the parent object, but not nested resources.
// VULNERABLE: Checks ticket ownership, not comment ownership
app.delete('/api/tickets/:ticketId/comments/:commentId', async (req, res) => {
const ticket = await verifyTicketAccess(req.user, req.params.ticketId);
// Oops: doesn't verify comment belongs to this ticket!
await db.comments.delete(req.params.commentId);
}); Prevention Patterns
1. Always Include User Context in Queries
The safest pattern is to include the user's ID in every query. You can't return records you never fetch.
// SECURE: User ID is part of the query
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.id,
userId: req.user.id, // Only finds if user owns it
});
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
}); 2. Centralize Authorization Logic
Don't scatter authorization checks across handlers. Build it into your data access layer.
// Policy-based access control
class OrderRepository {
async findById(id, requestingUser) {
const order = await db.orders.findById(id);
if (!order) return null;
// Centralized authorization rules
const canAccess =
order.userId === requestingUser.id ||
requestingUser.roles.includes('Admin') ||
order.sharedWith.includes(requestingUser.id);
if (!canAccess) {
auditLog('AUTHZ_DENIED', { userId: requestingUser.id, orderId: id });
return null; // Looks like 404 to prevent enumeration
}
return order;
}
} 3. Use UUIDs Instead of Sequential IDs
UUIDs don't prevent IDOR, but they make enumeration impractical. Combine with proper authorization for defense in depth.
4. Verify Full Object Chains
For nested resources, verify the entire chain of ownership.
// SECURE: Verifies comment belongs to ticket belongs to user
app.delete('/api/tickets/:ticketId/comments/:commentId', async (req, res) => {
const comment = await db.comments.findOne({
id: req.params.commentId,
ticketId: req.params.ticketId, // Comment must belong to ticket
});
if (!comment) return res.status(404).json({ error: 'Not found' });
// Now verify ticket access
const ticket = await verifyTicketAccess(req.user, req.params.ticketId);
if (!ticket) return res.status(404).json({ error: 'Not found' });
await db.comments.delete(comment.id);
}); Testing for IDOR
- Log in as User A, note resource IDs they can access
- Log in as User B (or use a different token)
- Try accessing User A's resources with User B's credentials
- Check not just GET, but POST, PUT, DELETE operations
- Test nested resources and indirect references
Takeaway
Every time you fetch a record by ID, ask yourself: "Am I verifying this user should see this specific record?" If the answer isn't clearly yes, you probably have an IDOR.