IDOR: When Your API Lets Anyone Access Anyone Else's Data 🔢🚪
Picture this: You're a developer. You've built a slick user profile API. You test it, it works perfectly — you can fetch YOUR profile. You ship it. You celebrate with a coffee. ☕
Three weeks later, a security researcher sends you a report: "Hey, I changed the id parameter from 123 to 124 and got someone else's data. Here's their full name, email, home address, and payment history."
Your coffee gets cold very fast. 🥶
Welcome to IDOR — Insecure Direct Object Reference — the vulnerability that's been sitting at the top of OWASP's charts for years and still trips up experienced developers daily.
What the Heck Is IDOR? 🤔
IDOR happens when your application uses a user-supplied identifier (like an ID in a URL or request body) to access objects without checking if the requesting user is actually allowed to see that object.
It's embarrassingly simple. The attacker doesn't need to hack your database, crack passwords, or run fancy exploits. They just... change a number.
GET /api/orders/1001 → attacker's own order ✅
GET /api/orders/1002 → someone else's order 😱
GET /api/orders/1003 → another stranger's order 😱😱
The worst part? It's not a bug in your framework or database. It's a logic error. Your code does exactly what you wrote — it just doesn't check who is asking.
Real-World Damage 🔥
IDOR isn't theoretical. Some famous real incidents:
- Facebook (2012): An IDOR in the friend removal flow let researchers delete anyone's friends. Awkward.
- Venmo (2018): User transactions were publicly accessible by sequentially iterating transaction IDs.
- Australian health insurance provider (2023): Patient medical records exposed via IDOR. Millions of records. Regulatory nightmare.
And these are just the ones that made headlines. Bug bounty hunters find IDOR in production apps every single day.
The Vulnerable Code 🩸
Here's what the dangerous version looks like — something you might write on a normal Tuesday afternoon without a second thought:
// Express.js — the vulnerable version
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// 🚨 WHERE IS THE AUTHORIZATION CHECK?!
return res.json(order);
});
See the problem? The authenticate middleware confirms the user is logged in, but nothing checks that the authenticated user owns this order. Any authenticated user can request any order ID.
This is the distinction that trips people up: Authentication ≠ Authorization.
- Authentication: "Are you who you say you are?" ✅
- Authorization: "Are you allowed to do THIS specific thing?" ❓
The Fixed Version ✅
The fix is conceptually simple — always verify ownership:
// Express.js — the secure version
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// ✅ Check that THIS user owns THIS order
if (order.userId.toString() !== req.user.id.toString()) {
// Return 404, not 403 — don't confirm the resource exists!
return res.status(404).json({ error: 'Order not found' });
}
return res.json(order);
});
Two things to notice here:
- The ownership check —
order.userId !== req.user.id. Simple. One line. Prevents the whole attack. - Return 404, not 403 — If you return 403 Forbidden, you're telling the attacker "this resource exists, you just can't see it." A 404 reveals nothing. Sneaky but smart. 🥷
IDOR In Disguise 🎭
IDOR doesn't always look like sequential integers in a URL. Watch out for these flavors:
In request bodies:
{ "accountId": "555", "amount": 100, "action": "transfer" }
What stops an attacker from changing "accountId": "555" to someone else's account?
In file downloads:
GET /api/invoices/download?file=invoice_user123_jan2026.pdf
Change the filename. Get someone else's invoice. PDF exfiltration party.
In GraphQL queries:
query {
user(id: "abc123") {
email
creditCards { last4, expiry }
}
}
GraphQL is especially dangerous here — a single misconfigured resolver exposes data across the entire graph.
In password reset flows:
POST /api/reset-password
{ "resetToken": "xyz", "userId": "456", "newPassword": "hacked123" }
If you trust the client-supplied userId instead of deriving it from the token... yeah. Don't do that.
Defense Strategies 🛡️
Strategy 1: Always Filter By Authenticated User
The golden rule: never fetch resources without scoping the query to the current user.
// BAD: Trusting the user-supplied ID alone
const order = await Order.findById(req.params.orderId);
// GOOD: Scoping the query to the authenticated user
const order = await Order.findOne({
_id: req.params.orderId,
userId: req.user.id // The DB query itself enforces ownership
});
This approach is elegant because even if your authorization check code has a bug, the query simply returns null for records the user doesn't own.
Strategy 2: Use Non-Sequential, Unpredictable IDs
Sequential integers (/orders/1001, /orders/1002) are basically an invitation to enumerate. Use UUIDs instead:
const { v4: uuidv4 } = require('uuid');
const order = new Order({
id: uuidv4(), // "a3f1c2d4-8b7e-4f2a-9c3d-1e5f6a7b8c9d"
userId: req.user.id,
// ...
});
UUIDs don't prevent IDOR — a determined attacker with a valid UUID can still try it — but they eliminate the easy "increment and iterate" attack. Defense in depth.
Strategy 3: Role-Based Access Control (RBAC)
For admin endpoints or resources that multiple users can access with different permissions, implement proper RBAC:
app.delete('/api/users/:userId', authenticate, authorize('admin'), async (req, res) => {
// Only admins reach here
await User.findByIdAndDelete(req.params.userId);
res.json({ message: 'User deleted' });
});
function authorize(requiredRole) {
return (req, res, next) => {
if (req.user.role !== requiredRole) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
Quick Security Checklist ✅
Before you ship that API endpoint, run through this:
- Does the endpoint access a resource by ID? → Add an ownership check
- Are resource IDs sequential integers? → Consider switching to UUIDs
- Does any endpoint accept a user/account ID from the client? → Derive it from the auth token instead
- Do file downloads use user-supplied filenames? → Validate against a whitelist tied to the user's account
- Have you tested by logging in as User A and requesting User B's resources? → Actually do this!
That last one is the most important. Manually test your own authorization. It takes 5 minutes and catches the issue before the bug bounty hunter does.
The Mindset Shift 🧠
The core lesson isn't about a specific line of code — it's a mental model change.
Every time you write code that fetches data using a user-supplied identifier, ask yourself: "What prevents User A from using User B's identifier?"
If your answer is "nothing, but why would they?" — you've found an IDOR vulnerability. Fix it before someone else does.
IDOR is #1 in OWASP Top 10 (as part of Broken Access Control) for a reason: it's everywhere, it's impactful, and it's completely preventable with a single ownership check. Two seconds of thinking during development versus a catastrophic data breach in production. Easy math. 🔐
Found an IDOR in the wild or want to share your war stories? Connect with me on LinkedIn — I love talking API security!
Curious about more security pitfalls? Check out my GitHub for projects and security-focused code examples. Stay paranoid out there! 🛡️
Remember: Your API is only as secure as its least-checked endpoint. 🔒