IDOR: The Vulnerability Hiding in Plain Sight (And Costing Millions) šµļøš
True story: A developer at a major e-commerce startup once changed the ?order_id=1001 in a URL to ?order_id=1002 out of curiosity. He got back a complete stranger's order ā name, address, items, everything. He responsibly disclosed it. The bug bounty? $15,000. The company's embarrassment? Priceless. š¬
That's IDOR in a nutshell ā and it's shockingly common.
IDOR (Insecure Direct Object Reference) sits at #1 in the OWASP API Security Top 10. Not SQL injection. Not XSS. Plain old "I changed a number in the URL and got someone else's data." Let's talk about why it keeps happening and, more importantly, how to stop it.
What Even Is IDOR? š¤
IDOR happens when your application uses a user-controlled value ā an ID, filename, account number ā to directly fetch an object from storage, without verifying the requesting user actually owns it.
The attack looks almost embarrassingly simple:
GET /api/invoices/1042
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
HTTP/1.1 200 OK
{
"invoice_id": 1042,
"user_id": 9983,
"total": "$3,200.00",
"card_last4": "4242"
}
The attacker (logged in as user 9983) just tries:
GET /api/invoices/1043
HTTP/1.1 200 OK
{
"invoice_id": 1043,
"user_id": 9984, ā Someone else's invoice!
"total": "$12,500.00",
"card_last4": "1337"
}
That's it. No SQL injection, no XSS, no fancy exploit. Just increment a number. The API was so helpfully open that it served up a stranger's financial data without batting an eye. š«
Why Does IDOR Keep Happening? š¤
Developers usually authenticate requests (check the JWT, verify the session). But they often forget the second step: authorization (check that this user can access this specific resource).
Here's the thought process that leads to IDOR:
ā
"The user is logged in" ā (Authentication ā most devs do this)
ā "The user owns this resource" ā (Authorization ā often forgotten)
Classic vulnerable endpoint in Node.js/Express:
// ā VULNERABLE ā Don't ship this!
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: 'Not found' });
// No ownership check! Any authenticated user can fetch any order!
return res.json(order);
});
The developer thought: "Only logged-in users can hit this endpoint, so it's fine." But any logged-in user can hit it ā including the curious attacker who just increments IDs.
The fix is one line of logic:
// ā
SAFE ā This is what you actually want
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: 'Not found' });
// Authorization check ā does this user own this order?
if (order.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
return res.json(order);
});
One equality check. That's it. The difference between a $15,000 bug bounty payout and a night of peaceful sleep. š
The IDOR Hall of Shame šļø
This isn't academic. Real companies, real breaches, real money:
- Venmo (2018): Public transaction feed exposed who paid whom for what. Every transaction was IDOR-accessible without authentication at all. 200 million transactions scraped.
- Experian (2021): A partner API let anyone look up anyone else's credit score ā just supply a different SSN or name. No authorization checks on who could query whose record.
- Instagram (2019): Private photos accessible via predictable media IDs. Someone with a valid session could cycle through IDs and pull photos from private accounts.
Pattern: All massive, well-funded companies. All "obvious in hindsight" authorization bugs. None involving sophisticated exploits.
Not Just Numbers: IDOR Comes in Many Forms š¾
IDOR isn't just sequential integers. Anything user-controlled that references a resource counts:
# Numeric IDs (classic)
GET /api/users/4812/profile
# UUIDs (harder to guess, but still IDOR if unchecked!)
GET /api/documents/a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Filenames
GET /api/download?file=user_4812_tax_return.pdf
# Usernames / slugs
GET /api/profiles/johndoe
# Indirect references in POST bodies
POST /api/messages
{ "to_user_id": 9999, "message": "Hello!" }
UUIDs are a common false sense of security. Yes, they're hard to guess ā but if your API ever leaks a UUID (in a list endpoint, a log, a referral link), the authorization check is the only thing standing between your users and their data being exposed.
Defense in Depth: The IDOR-Proof Checklist š”ļø
1. Always query with ownership in the WHERE clause
Instead of fetching by ID then checking ownership in application code, bake the ownership into the query itself:
// ā
Better pattern ā ownership is enforced at database level
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
// If this user doesn't own the order, findOne returns null
const order = await Order.findOne({
_id: req.params.orderId,
userId: req.user.id // ā Ownership baked into query
});
if (!order) return res.status(404).json({ error: 'Not found' });
return res.json(order);
});
This is cleaner and safer ā there's no separate check to forget. If the record isn't yours, you get 404 (not even 403, which confirms the resource exists).
2. Use role-based middleware to centralise authorization
// Reusable ownership middleware
const requireOwnership = (Model, paramKey = 'id') => {
return async (req, res, next) => {
const resource = await Model.findOne({
_id: req.params[paramKey],
userId: req.user.id
});
if (!resource) return res.status(404).json({ error: 'Not found' });
req.resource = resource; // Attach for downstream handlers
next();
};
};
// Usage ā clean, consistent, hard to forget
app.get('/api/orders/:id', authenticate, requireOwnership(Order), (req, res) => {
res.json(req.resource);
});
app.delete('/api/orders/:id', authenticate, requireOwnership(Order), (req, res) => {
req.resource.deleteOne();
res.status(204).send();
});
3. Write automated tests that specifically probe IDOR
describe('Order API ā authorization', () => {
it('should NOT allow user B to read user A\'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}`);
// Must be 403 or 404, never 200!
expect(response.status).toBeGreaterThanOrEqual(403);
});
});
Most IDOR bugs would be caught immediately with tests like this. Sadly, most codebases only test the happy path (user A reads user A's order). Always test the adversarial path too.
The UUID Trap: "But We Use UUIDs!" š²
I hear this constantly. UUIDs are 122 bits of randomness ā no one can guess them! Right?
The problem: UUIDs get leaked all the time.
- Your list endpoint returns
[{id: "abc-123", ...}, {id: "def-456", ...}]ā IDs exposed - Your notification email includes
/invoices/abc-123ā ID in the wild - Your logs include the full request URL ā ID in your monitoring tool
- A user shares a link with a friend ā ID in the browser history
Once a UUID is in the wild, it can be used. If there's no authorization check, UUID == false security.
UUIDs reduce the risk of enumeration attacks. They don't replace authorization checks. Use both. š
Quick Win: Audit Your Own App Right Now š
Open your app and find 3 GET endpoints that take an ID parameter. For each one, ask:
- What happens if I use a different user's session and hit this endpoint with someone else's ID?
- Is ownership verified in the query, in middleware, or not at all?
- Do my tests cover this cross-user scenario?
If question 3 is "not at all" for any endpoint ā that's your bug to fix before a researcher does it for you and submits a very detailed bug bounty report. š
The Bottom Line šÆ
IDOR is the vulnerability that shouldn't exist ā because the fix is straightforward ā and yet it remains the #1 API security issue year after year. It happens because:
- Authentication and authorization get conflated
- Happy-path testing dominates
- "It's behind a login" feels like protection
The three rules:
- Authenticate: Is this user who they say they are? (JWT, session, etc.)
- Authorize: Does this user have permission to access this specific resource?
- Test it: Write tests that deliberately use the wrong user's session
Authentication without authorization is a door with a lock that opens for any key. šļø
Found an IDOR in the wild? Connect on LinkedIn ā I love a good responsible disclosure story!
Want to see authorization patterns in real APIs? Browse GitHub ā I document the security decisions in every project.
P.S. ā Seriously, go check your /api/:resource/:id endpoints right now. I'll wait. š
P.P.S. ā If you find your own IDOR, fix it immediately. If someone else finds it first... start preparing your incident response blog post. š¬