0x55aa
← Back to Blog

IDOR: The Vulnerability That Lets Anyone Read Your Users' Data 🔓👀

6 min read

IDOR: The Vulnerability That Lets Anyone Read Your Users' Data 🔓👀

Let me paint you a picture.

You spend three weeks building a slick REST API. Auth middleware? Check. Rate limiting? Check. HTTPS? Obviously. You feel good. You deploy. You go home. Someone changes ?invoice_id=1001 to ?invoice_id=1002 in their browser and downloads your other user's invoice. 💀

Welcome to IDOR — Insecure Direct Object Reference — the vulnerability that consistently ranks in the OWASP Top 10, pays out millions in bug bounties every year, and is still found in Fortune 500 apps daily. Not because developers are careless. Because it's sneaky.

What Is IDOR, Exactly? 🤔

IDOR happens when your application exposes a reference to an internal object (a database ID, filename, or account number) and trusts the user to only request what they're allowed to see — without actually checking.

The attack is devastatingly simple:

GET /api/orders/5823        → Your order ✅
GET /api/orders/5824        → Someone else's order... also works 😱
GET /api/orders/5825        → And this one too 🔥

No token forging. No SQLi. No buffer overflows. Just increment a number. It's the digital equivalent of trying every locker until one opens.

Real-world damage: In 2023, a bug bounty hunter found IDOR in a major airline's API — by changing a booking reference in a request, they could retrieve any passenger's name, passport number, seat assignment, and travel history. That's GDPR nightmare territory. The payout? $50,000. The airline's embarrassment? Priceless.

The Classic Mistake: Sequential IDs

Here's the vulnerable pattern you've probably written (no judgment, we all have):

// Express.js — The IDOR hall of shame
app.get('/api/invoices/:id', authenticateUser, async (req, res) => {
  const invoice = await Invoice.findById(req.params.id);

  if (!invoice) {
    return res.status(404).json({ error: 'Not found' });
  }

  // ⚠️ We checked auth... but NOT ownership!
  return res.json(invoice);
});

The authenticateUser middleware confirms you're logged in. It says nothing about whether invoice #5824 belongs to you. That's the gap attackers exploit.

The fix is one extra check — but developers forget it when they're moving fast:

// Express.js — The safe version
app.get('/api/invoices/:id', authenticateUser, async (req, res) => {
  const invoice = await Invoice.findOne({
    _id: req.params.id,
    userId: req.user.id,   // 👈 THIS is the line that matters
  });

  if (!invoice) {
    // Return 404, not 403 — don't leak whether the resource exists
    return res.status(404).json({ error: 'Not found' });
  }

  return res.json(invoice);
});

Always scope your query to the authenticated user. If the object doesn't belong to them, it shouldn't exist as far as they're concerned.

IDOR Goes Beyond Simple IDs

Here's where developers get overconfident: "I switched from auto-increment IDs to UUIDs, so I'm safe!"

Not quite. UUIDs are harder to guess, but IDOR is about authorization, not obscurity. If your app leaks UUIDs through other endpoints (search results, activity feeds, email previews), attackers collect them and use them later.

IDOR also hides in unexpected places:

# File download
GET /api/files/download?filename=user_report_1001.pdf

# Password reset token (if predictable)
POST /api/reset-password?token=reset_1001

# Account actions
POST /api/admin/disable-user
Body: { "userId": "456" }   ← Any logged-in user can disable anyone?

# Indirect IDOR via relationship
GET /api/messages/thread/789   ← Thread belongs to another user

The pattern is always the same: a reference your app trusts the client to respect, without enforcing it server-side.

The Right Mental Model: Never Trust the Client

The fix isn't just adding userId: req.user.id everywhere (though that's the tactical answer). The strategic fix is a mindset shift:

Every request that touches a resource must answer: "Does the requester own or have permission to access this specific object?"

Here's a reusable middleware pattern I like for Express apps:

// Ownership middleware factory
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; // Pass it along so the handler doesn't re-query
    next();
  };
};

// Usage — clean and consistent
app.get(
  '/api/invoices/:id',
  authenticateUser,
  requireOwnership(Invoice),
  (req, res) => res.json(req.resource)
);

app.delete(
  '/api/documents/:id',
  authenticateUser,
  requireOwnership(Document),
  async (req, res) => {
    await req.resource.deleteOne();
    res.json({ message: 'Deleted' });
  }
);

This pattern makes ownership checking impossible to forget because it's baked into the route definition. If you don't include requireOwnership, the gap is visually obvious in code review.

Testing for IDOR in Your Own App 🕵️

Before attackers find it, find it yourself. The process is straightforward:

  1. Create two test accounts — User A and User B
  2. Log in as User A, create resources (posts, orders, files)
  3. Note the IDs returned in responses
  4. Log in as User B, attempt to access User A's resource IDs directly
  5. If you get data back instead of a 404 — you have IDOR

For APIs, keep an eye on your browser's network tab or use a proxy like Burp Suite. Every ID you see in a response is a potential IDOR target if that endpoint lacks ownership checks.

Also check for horizontal vs vertical IDOR:

  • Horizontal: User A accesses User B's data (same privilege level)
  • Vertical: Regular user accesses admin-only data (/api/admin/users/123)

Both are critical. Both are common.

The Security Return on Investment

IDOR fixes are among the highest ROI security improvements you can make:

  • Effort: One extra query condition per endpoint (minutes of work)
  • Impact prevented: Full data breach, regulatory fines, user trust destroyed

Bug bounty hunters love IDOR because it's easy to find and pays well. Make their job boring — scope every query to the authenticated principal, return 404 (not 403) when ownership fails, and test with two accounts before every release.

Your users handed you their data. Returning the favour is the minimum requirement. 🔒


Shipping APIs and want to talk security? Hit me up on LinkedIn — I collect IDOR war stories.

Want to see what secure-by-default looks like in practice? Browse my GitHub — ownership checks included.

P.S. — Right now, open your codebase and search for .findById(req.params.id). Count how many results lack a userId filter. That number is your IDOR surface area. Fix it this sprint. 🔍