0x55aa
← Back to Blog

IDOR: The API Bug That Lets Anyone Read Your Users' Data 🔓

6 min read

IDOR: The API Bug That Lets Anyone Read Your Users' Data 🔓

Imagine you just signed up for a new app. You check your profile URL and notice:

https://coolapp.com/api/users/1042/profile

You're user 1042. So you wonder... what happens if you type 1041?

You hit Enter. And there it is — someone else's full profile. Name, email, phone number, home address. Everything.

Congratulations, you just found an IDOR vulnerability — and it's one of the most common, most embarrassing, and most devastating bugs in modern web applications.

What Is IDOR? 🤔

IDOR stands for Insecure Direct Object Reference. The name sounds academic, but the concept is painfully simple:

Your API gives users a direct reference to an object (like a user ID, order number, or file name) and trusts them not to change it.

Spoiler: they will change it.

IDOR has been in the OWASP Top 10 for years (it lives under "Broken Access Control", now ranked #1). It's responsible for some of the biggest data breaches in history — and it takes about 30 seconds to check for.

The Anatomy of an IDOR Attack 🎭

Here's a classic vulnerable endpoint:

// ❌ VULNERABLE: Trusts the user-supplied ID
app.get('/api/orders/:orderId', async (req, res) => {
  const order = await Order.findById(req.params.orderId);

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

  // BUG: Never checked if this order belongs to the logged-in user!
  return res.json(order);
});

The dev added authentication (the user must be logged in to hit this endpoint) but forgot authorization (does this user have the right to see this specific order?).

An attacker just loops through order IDs:

# Script a kid could write in 5 minutes
for i in $(seq 1 10000); do
  curl -s -H "Authorization: Bearer MY_TOKEN" \
    https://target.com/api/orders/$i \
    | grep -v '"error"'
done

They'll harvest every order in your system. Names, addresses, purchase history — the works.

The Fix: Always Check Ownership 🛡️

The solution is exactly one line of thinking: "Does the requesting user own this resource?"

// ✅ SECURE: Verify ownership before returning data
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' });
  }

  // ✅ The critical check — does this order belong to the logged-in user?
  if (order.userId !== req.user.id) {
    // Return 404, not 403 — don't confirm the resource exists!
    return res.status(404).json({ error: 'Not found' });
  }

  return res.json(order);
});

Two things to notice:

  1. We check order.userId !== req.user.id before returning anything.
  2. We return 404 instead of 403 when the user doesn't own the resource. Why? A 403 tells the attacker "this exists, you just can't see it." A 404 gives them nothing to work with.

IDOR Isn't Just About IDs 📁

Sequential integers are obvious targets, but IDOR hides in many forms:

  • File names: /api/invoices/download?file=invoice_1042.pdf → change to invoice_1041.pdf
  • UUIDs: Yes, even UUIDs! If there's no ownership check, guessing doesn't matter — APIs often return the UUID in one response, letting you reference it in another
  • Hashed values: /reset-password?token=abc123 where tokens are predictable or reusable
  • Indirect references: /api/messages?thread=9876 — thread IDs are just IDs too
// ❌ Laravel example — UUID doesn't save you without the check
public function downloadInvoice(Request $request)
{
    $invoice = Invoice::where('uuid', $request->uuid)->firstOrFail();

    // BUG: Anyone with a valid UUID (from *any* response) can download this
    return Storage::download($invoice->file_path);
}

// ✅ Fixed
public function downloadInvoice(Request $request)
{
    $invoice = Invoice::where('uuid', $request->uuid)
        ->where('user_id', auth()->id())  // 👈 ownership check
        ->firstOrFail();

    return Storage::download($invoice->file_path);
}

Real-World IDOR Hall of Shame 😬

These aren't hypotheticals — IDOR has taken down real companies:

  • Venmo (2019): Public transaction feed exposed who was paying whom. Mass scraping revealed sensitive financial relationships.
  • Parler (2021): Sequential post IDs with no access controls. Every post, deleted or not, was harvested by researchers before the platform went offline.
  • Ford Motor (2021): IDOR in an internal portal exposed sensitive customer and employee records across all accounts.
  • USPS (2018): An IDOR in their Informed Delivery API exposed 60 million users' account details to any authenticated user.

Notice a pattern? Authentication was present. Authorization was missing.

Quick Checklist: Is Your API Safe? ✅

Before you ship, ask yourself for every endpoint:

  • Does this return or modify user-specific data?
  • Am I checking that the requesting user owns this specific resource?
  • Am I checking roles/permissions for admin-only resources?
  • Do I return 404 (not 403) for resources the user shouldn't know exist?
  • Did I test by logging in as User A and trying to access User B's data?

That last point is the most important. Test it yourself. Create two accounts in your local environment, grab a token from Account A, and try hitting Account B's endpoints. If you can read B's data as A, you have an IDOR.

The Mental Model to Carry Forever 🧠

Here's the mindset that prevents IDOR for life:

Authentication asks: "Who are you?" Authorization asks: "Are you allowed to do THIS to THIS specific thing?"

Most developers nail authentication. It's the authorization question — applied at the resource level, not just the route level — that gets people in trouble.

Add a user_id scope to every query that touches user-owned data. Make it automatic. Make it impossible to forget. In Laravel, use global scopes. In Rails, scope through associations. In any ORM, build a pattern where you start from the authenticated user and traverse to their resources, rather than fetching by ID and checking afterward.


IDOR is embarrassing to get caught with — it's a beginner mistake that pros make all the time because it's invisible until someone looks for it. Now you know what to look for.

Go audit your API right now. You might be surprised what you find.

Found this useful? Follow me on Twitter/X and GitHub for more security deep-dives, or drop a comment if you've found (and fixed!) an IDOR in the wild. War stories welcome. 🛡️