0x55aa
Back to Blog

IDOR: The Vulnerability Hiding in Every API You've Ever Built 🔓

|
7 min read

IDOR: The Vulnerability Hiding in Every API You've Ever Built 🔓

You built an invoicing app. Users log in, click "My Invoices", and see their own data. Beautiful.

Now watch what happens when a curious user changes the URL from:

GET /api/invoices/1042

to:

GET /api/invoices/1001

If your server responded with someone else's invoice — congratulations, you just shipped IDOR. And somewhere out there, a hacker is running a script that just downloaded every invoice you've ever issued. 😬

What Even Is IDOR? 🤔

IDOR = Insecure Direct Object Reference.

It's a clunky name for a simple idea: your application exposes an internal identifier (a database ID, a filename, an order number) and then doesn't bother checking whether the person requesting it actually owns it.

It's consistently in the OWASP Top 10, it's responsible for some of the most embarrassing data breaches in recent history, and — the truly fun part — it's trivially easy to introduce and surprisingly easy to miss in code review.

The reason developers miss it? The code looks correct. The endpoint authenticates the user. The query fetches the right row. Nobody noticed there was no check in the middle confirming the user is allowed to see that row.

The Classic: Numeric Sequential IDs 🔢

Here's how it shows up in roughly 70% of apps:

// Express.js — looks fine, works perfectly, is completely broken
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = ?',
    [req.params.orderId]
  );

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

  res.json(order); // 💥 No ownership check!
});

The authenticate middleware confirms you're logged in. But it never asks: is this YOUR order?

Attacker's script:

import requests

headers = {"Authorization": "Bearer attacker_jwt_token"}

for order_id in range(1, 100000):
    r = requests.get(f"https://yourapp.com/api/orders/{order_id}", headers=headers)
    if r.status_code == 200:
        print(f"[+] Got order {order_id}: {r.json()}")

Runs in minutes. Every order in your database, exfiltrated over breakfast. ☕

The fix is one line:

app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = ? AND user_id = ?', // 👈 ownership check
    [req.params.orderId, req.user.id]
  );

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

  res.json(order);
});

Now the query returns nothing unless the authenticated user owns that order. Simple. Effective. Should have been there from the start.

The Sneaky Variant: UUIDs Don't Save You 🎲

"I use UUIDs instead of sequential integers, so it's unguessable — I'm fine!"

I hear this constantly. And it's... sort of true. Guessing a UUID is hard. But attackers don't need to guess. They:

  • Use their own account to generate a legitimate UUID
  • Pass that UUID from account A while authenticated as account B
  • Find UUIDs leaked in logs, error messages, or other API responses
  • Exploit a second endpoint that lists UUIDs they shouldn't see

UUIDs reduce the enumeration attack surface. They do not replace authorization checks. The rule is: obscurity is not access control.

The Forgotten One: File Downloads 📁

IDOR isn't just about database rows. Any reference to a server-side resource counts:

// File download endpoint — classic IDOR
app.get('/download', authenticate, (req, res) => {
  const filename = req.query.file; // e.g., "invoice_1042.pdf"

  // No check: does this user own invoice_1042?
  res.download(path.join('/app/invoices', filename));
});

Attacker downloads /download?file=invoice_1001.pdf. Gets someone else's invoice. Changes 1001 to 1 through 999999. Gets everyone's invoices.

Bonus vulnerability: if there's no path traversal check, ?file=../../../etc/passwd also works. Two-for-one bug special! 🎁

Real-World IDOR Hall of Fame 🏆

These are public incidents, not hypotheticals:

Venmo (2019): The default transaction privacy was "public." Every payment between users was enumerable. Researchers pulled millions of transactions and mapped drug purchase patterns. The reference object wasn't even guessed — it was public by design.

Instagram (2015): A researcher found an IDOR that let any authenticated user reset anyone else's password by manipulating a user ID in a POST body. Account takeover on demand. Bug bounty payout: $10,000.

US Postal Service (2018): An API exposed package tracking data for any user by changing a numeric ID. 60 million accounts were accessible to anyone with a login.

The pattern: developers built authentication. Nobody built authorization.

How to Think About This Systematically 🧠

Every time you write an endpoint that fetches, updates, or deletes a resource, ask yourself three questions:

  1. Who owns this resource? (User, organization, admin only?)
  2. Am I checking ownership in the query or in code? (Checking in the query is safer — it's atomic)
  3. Can the client influence which resource is selected? (URL param, query string, request body — all of these are user-controlled)

For Laravel developers, a clean pattern using policies:

// routes/api.php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('auth:sanctum');

// InvoiceController.php
public function show(Invoice $invoice): JsonResponse
{
    // authorize() throws 403 if the policy denies access
    $this->authorize('view', $invoice);

    return response()->json($invoice);
}

// InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
    return $user->id === $invoice->user_id; // ownership check lives here
}

The policy is reusable, testable, and keeps authorization logic in one place instead of scattered across every controller method.

Your IDOR Audit Checklist ✅

Run through this on your next code review:

  • Every endpoint that takes a resource ID checks ownership before returning data
  • Ownership checks happen server-side, never trust client claims ("I own this")
  • File downloads validate the requesting user owns the referenced file
  • Bulk/list endpoints filter by user_id (not just "is logged in")
  • Admin-only resources have explicit role checks, not just "is authenticated"
  • Error responses for unauthorized access return 403, not 404 with data (leaking that the resource exists)
  • UUIDs are used in addition to authorization, not instead of it

The last point trips people up. Returning 404 for unauthorized access is sometimes done to avoid confirming a resource exists (security through obscurity). Returning 403 is cleaner. Pick a consistent approach and document it.

Quick Manual Test You Can Run Right Now 🔍

  1. Open your app and do something that generates a resource (place an order, upload a file, create a record)
  2. Copy the resource ID from the URL or API response
  3. Log in as a different user
  4. Request that resource ID directly
  5. Did you get data? You have IDOR.

If you want to go deeper, tools like Burp Suite have IDOR-specific scanning. OWASP's testing guide has a full methodology. But the manual test above finds 80% of real-world cases in five minutes.

The Bottom Line

IDOR is embarrassing because it's so preventable. You built authentication. You built the query. You just forgot the one line that connects them: is the authenticated user allowed to see THIS specific thing?

Authentication says "I know who you are."
Authorization says "I know what you're allowed to do."

You need both. Every time. No exceptions.


Found an IDOR in your codebase? Don't panic — fix it, audit the rest, and share the lesson on LinkedIn. The security community learns when developers talk openly about this stuff.

Want more security deep-dives? Check out my GitHub for secure code patterns and real-world examples.

P.S. — Go find one of your API endpoints that takes an ID and swap it for another user's ID. Do it now. I'll wait. 🔓

Thanks for reading!

Back to all posts