0x55aa
Back to Blog

IDOR: The Vulnerability Hiding in Plain Sight (And Costing Millions) šŸ•µļøšŸ”“

|
8 min read

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:

  1. What happens if I use a different user's session and hit this endpoint with someone else's ID?
  2. Is ownership verified in the query, in middleware, or not at all?
  3. 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:

  1. Authentication and authorization get conflated
  2. Happy-path testing dominates
  3. "It's behind a login" feels like protection

The three rules:

  1. Authenticate: Is this user who they say they are? (JWT, session, etc.)
  2. Authorize: Does this user have permission to access this specific resource?
  3. 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. 😬

Thanks for reading!

Back to all posts