IDOR: The API Flaw Hiding in Plain Sight šµļøāāļøš
IDOR: The API Flaw Hiding in Plain Sight šµļøāāļøš
Let me paint you a picture.
You're using a shiny new fintech app. You notice your invoice URL looks like this:
https://api.coolfintech.com/invoices/10482
Curiosity strikes. You change 10482 to 10481. Boom ā someone else's invoice loads. Their name, address, bank details, everything.
Congratulations, you just discovered an IDOR vulnerability ā and you didn't even need a hacking tool. Just a keyboard and a suspicious mind.
This is Insecure Direct Object Reference (IDOR), ranked in the OWASP API Security Top 10 as one of the most widespread and damaging flaws in modern applications. And the truly painful part? It takes about 30 seconds to accidentally introduce, and months to notice if you're not actively looking.
What Even Is IDOR? š¤
IDOR happens when your application exposes a reference to an internal resource ā a database ID, a filename, a UUID ā and trusts the user to only access resources that belong to them. Without actually checking that.
That's it. That's the whole bug. The authentication works fine. The endpoints are protected. But the authorization ā "does this specific user have access to this specific resource?" ā is missing.
Authentication: "Are you logged in?" ā Authorization: "Is this yours?" ā (the forgotten sibling)
It's like a hotel that checks you have a room key, but every key opens every door. You're authenticated. Just not authorized. šØš
A Vulnerable API (Don't Ship This) š«
Here's a classic Node.js/Express endpoint that looks totally fine at first glance:
// ā VULNERABLE: No ownership check!
app.get('/api/orders/:orderId', authenticateUser, async (req, res) => {
const order = await Order.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// ā ļø We verified the user is logged in (authenticateUser middleware),
// but we NEVER checked if this order belongs to them!
return res.json(order);
});
A logged-in attacker can enumerate every order in your system by iterating orderId. Sequential integer IDs make this trivially scriptable:
# Attacker's "exploit" ā it's just a for loop š¬
for i in $(seq 1 10000); do
curl -H "Authorization: Bearer ATTACKER_TOKEN" \
https://api.yourapp.com/api/orders/$i
done
That's not hacking. That's a for loop. And it will harvest every order in your database.
The Fix: Always Check Ownership š”ļø
The fix is exactly one line of logic ā but you have to remember to apply it everywhere:
// ā
SAFE: Ownership is enforced server-side
app.get('/api/orders/:orderId', authenticateUser, async (req, res) => {
const order = await Order.findOne({
_id: req.params.orderId,
userId: req.user.id // š This is the entire fix
});
if (!order) {
// Return 404 whether it doesn't exist OR belongs to someone else
// Never reveal "this exists but isn't yours" ā that leaks info!
return res.status(404).json({ error: 'Order not found' });
}
return res.json(order);
});
By including userId: req.user.id in the query, the database only returns the order if it belongs to the authenticated user. No ownership? No record returned. Simple, effective, and nearly impossible to bypass.
Pro tip: Return 404 instead of 403 when something exists but doesn't belong to the user. A 403 tells attackers "this record exists, keep probing." A 404 tells them nothing useful. š¤«
The Sneakier Variants (Yes, There Are More) š
Simple sequential IDs are obvious. But IDOR hides in subtler places too:
File downloads via user-controlled paths:
GET /api/exports/download?file=report_user_42.pdf
# Attacker tries: report_user_43.pdf, report_user_1.pdf
Updating other users' data:
PATCH /api/profile
{ "userId": 9999, "email": "[email protected]" }
# If the server uses the body's userId instead of the token's userId...
Mass assignment leading to IDOR:
// ā Trusting user input to set the owner
const record = await Record.create(req.body);
// Attacker sends: { "content": "...", "userId": 1 }
// They just created a record owned by user 1!
// ā
Always set sensitive fields from the authenticated session
const record = await Record.create({
content: req.body.content,
userId: req.user.id // Never trust this from the client!
});
The IDOR family is large. Anywhere you take an identifier from user input and use it to fetch or modify data, you have a potential IDOR.
How to Think About Authorization (The Right Mental Model) š§
The fix isn't just adding a userId check here and there. It's building an authorization mindset from the start.
Ask these questions for every single endpoint:
ā” Who is making this request? (Authentication)
ā” What resource are they requesting? (Input)
ā” Does this resource belong to them? (Authorization!)
ā” Do they have the right *role* to perform this action? (RBAC)
ā” Am I leaking existence of resources they shouldn't know about?
In Laravel, this maps beautifully to Policies:
// UserInvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id;
}
// InvoiceController.php
public function show(Invoice $invoice): JsonResponse
{
// One line ā enforces ownership automatically via Gate/Policy
$this->authorize('view', $invoice);
return response()->json($invoice);
}
Route model binding + policies = IDOR protection baked in. The framework handles the lookup AND the authorization check. Beautiful. šÆ
Real-World Impact: This Is Not Theoretical š„
IDOR has been found in some of the biggest platforms on the planet:
- Facebook (2015): IDOR allowed deleting any photo album on the platform. Rewarded $12,500 in bug bounty.
- Instagram: IDOR let attackers view private posts of any account.
- Shopify: Multiple IDOR bugs over the years in their merchant/admin APIs.
- Healthcare apps: Patient records accessible by changing a number in the URL. Real damage, real lives affected.
And countless smaller companies quietly patching these without disclosure, hoping nobody noticed. (They usually did.)
The stat that should keep you up at night: According to HackerOne's annual report, IDOR/Broken Object Level Authorization is consistently the #1 or #2 highest-paying bug bounty category. Why? Because it's everywhere, and the impact is always critical ā data of real users, leaking or being modified by someone who should never have touched it.
Your IDOR Audit Checklist ā
Go through your API right now and ask:
ā” Every GET endpoint: can a user fetch another user's resource by ID?
ā” Every PATCH/PUT/DELETE: does it verify ownership before modifying?
ā” Every file download: is the path/filename validated against ownership?
ā” Are you using sequential integers? (UUIDs don't prevent IDOR, but they slow brute-force)
ā” Does your ORM/framework have built-in scope/policy helpers you're not using?
ā” Are you returning 403 when you should return 404?
ā” Is your test suite actually testing cross-user access? (Write one NOW!)
The most valuable test you can write:
it('should not return another user\'s order', async () => {
const userA = await createUser();
const userB = await createUser();
const order = await createOrder({ userId: userA.id });
const response = await request(app)
.get(`/api/orders/${order.id}`)
.set('Authorization', `Bearer ${userB.token}`);
expect(response.status).toBe(404); // NOT 200!
});
If this test passes, you're safe. If it was never written, you might not be. šÆ
The Bottom Line š”
IDOR is the vulnerability that punishes the assumption that "authenticated = authorized." Your authentication layer is fine. Your data is exposed anyway.
The good news: the fix is genuinely simple once you know to look for it. Always scope queries to the authenticated user. Use framework authorization features (policies, gates, scopes). Write cross-user tests. Never trust client-provided owner IDs.
The bad news: it's easy to forget when you're shipping fast, especially under deadline pressure when "the auth middleware is already there, what else do we need?"
What else you need is one more question: Is this theirs?
Ask it every time. Your users' data depends on it. š
Found this useful? Connect with me on LinkedIn ā I share security deep-dives and developer tips regularly.
Got an IDOR story to share? Drop it on my GitHub or reach out ā the community learns best from real examples!
Now go write that cross-user test. Seriously. I'll wait. ā³šāØ
P.S. UUIDs instead of sequential integers reduce the discoverability of IDOR but absolutely do not fix it. An attacker with access to one UUID (e.g., from a shared link or API response) can still exploit IDOR. Always enforce ownership. UUIDs are a speed bump, not a wall.