0x55aa
โ† Back to Blog

IDOR: The Vulnerability Hiding in Plain Sight (And in Your URLs) ๐Ÿ”“๐Ÿ‘€

โ€ข7 min read

IDOR: The Vulnerability Hiding in Plain Sight (And in Your URLs) ๐Ÿ‘€๐Ÿ”“

True story: A developer at a well-known startup shipped an invoice download feature. The URL looked like this:

GET /invoices/download?id=1042

A curious (read: bored) QA tester changed 1042 to 1041. Downloaded someone else's invoice. Changed it to 1000. Downloaded the CEO's tax documents. Filed a bug report. Got a bonus. The developer had a very long Monday. ๐Ÿ˜…

Welcome to IDOR โ€” Insecure Direct Object Reference โ€” the vulnerability so simple it feels like a trick question, yet consistently ranks as OWASP's most critical access control flaw. It has leaked medical records, exposed private messages, and dumped millions of user accounts. The best part? It requires zero hacking skill. Just a browser and a curious mind.

Let's break it down.

What Even Is IDOR? ๐Ÿค”

IDOR happens when your app exposes an internal object reference (a database ID, filename, or key) and trusts the client to only access what they're allowed to โ€” without actually verifying it server-side.

In plain English: you show users the keys to every room, then just trust them not to try other keys.

The classic shapes IDOR takes:

# URL parameter
GET /api/orders/7823

# Query string
GET /api/profile?user_id=99

# POST body
POST /api/messages
{ "recipient_id": 456 }

# File path
GET /uploads/invoices/user_99_receipt.pdf

Every one of these is a potential IDOR if your server doesn't verify "does the currently authenticated user have permission to access THIS specific resource?"

A Vulnerable API โ€” Can You Spot It? ๐Ÿ•ต๏ธ

Here's the kind of code that ships to production every day:

// Express.js route โ€” dangerously naive
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: 'Order not found' });
  }

  // โŒ MISSING: Does req.user.id own this order?
  return res.json(order);
});

The developer added authenticate middleware โ€” so unauthenticated users are blocked. They feel secure. But any authenticated user (say, user_id=5) can request /api/orders/1 and get user_id=1's order. The authentication check is there; the authorization check is not.

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

Developers conflate these constantly. Don't be that developer. ๐Ÿ™

The Fix: Always Scope Queries to the Authenticated User ๐Ÿ”’

// Express.js route โ€” the right way
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.query(
    // โœ… Scope query to the logged-in user's ID
    'SELECT * FROM orders WHERE id = ? AND user_id = ?',
    [req.params.orderId, req.user.id]
  );

  if (!order) {
    // Don't reveal whether the order exists for another user
    return res.status(404).json({ error: 'Order not found' });
  }

  return res.json(order);
});

Two changes, massive security improvement:

  1. Scope the SQL query โ€” add AND user_id = ? so the database only returns rows the current user owns. Even if the attacker guesses the right orderId, it won't match their user_id, and the query returns nothing.

  2. Return 404, not 403 โ€” returning a 403 Forbidden tells the attacker "that resource exists, you just can't access it." A 404 leaks nothing. (This is called security through ambiguity, and it's the rare case where vagueness is actually correct.)

Laravel Example: The Elegant Solution ๐ŸŽฏ

If you're in Laravel-land, route model binding + policies make IDOR prevention almost automatic:

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

// app/Policies/OrderPolicy.php
class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        // โœ… Laravel checks this automatically before the controller runs
        return $user->id === $order->user_id;
    }
}

// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
    public function show(Order $order): JsonResponse
    {
        // If we get here, the policy already confirmed ownership
        $this->authorize('view', $order);

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

Laravel's authorize() calls the policy, which returns false if the authenticated user doesn't own the order โ€” and automatically throws a 403. One clean policy class protects every route that touches that model. Elegant. ๐ŸŽฉ

The Sneaky IDOR Variants That Catch Developers Off-Guard ๐Ÿ˜ˆ

1. Indirect IDOR (via a related object)

# Direct: attacker changes their own ID
GET /api/messages/9901  โ† obvious

# Indirect: attacker uses a related ID they own
POST /api/conversations/77/messages
{ "text": "Hello!" }

# The bug: conversation 77 belongs to another user
# but the app only checks if the user is logged in

Always verify ownership at every level of a nested resource.

2. Predictable sequential IDs

If your IDs are 1, 2, 3, 4โ€ฆ, attackers don't need to guess โ€” they just enumerate. Consider UUIDs for sensitive resources:

-- Instead of:
CREATE TABLE invoices (id BIGINT PRIMARY KEY AUTO_INCREMENT);
-- invoice/1, invoice/2, invoice/3... totally guessable ๐Ÿ˜ฌ

-- Use:
CREATE TABLE invoices (id UUID DEFAULT gen_random_uuid() PRIMARY KEY);
-- invoice/550e8400-e29b-41d4-a716-446655440000 โ€” good luck enumerating that!

UUIDs aren't a substitute for proper authorization checks (never use obscurity as your only defense!), but they add a meaningful speed bump for brute-force enumeration.

3. Mass assignment + IDOR combo

// "Just spread the request body into the update" โ€” famous last words
app.put('/api/profile', authenticate, async (req, res) => {
  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [req.body, req.user.id]  // req.body could include { "role": "admin" } ๐Ÿ˜ฑ
  );
});

Always whitelist the fields you accept, and never let users touch user_id, role, is_admin, or any field that changes permissions.

The IDOR Testing Checklist ๐Ÿงช

Before your next PR ships, spend 10 minutes running through this:

  • Can I swap my user ID in a URL/body and get someone else's data?
  • Can I increment/decrement any ID and access objects I don't own?
  • Do all my SQL queries scope results to the authenticated user?
  • Do my file download/upload endpoints verify ownership?
  • Are admin-only actions gated by role checks, not just authentication?
  • Do I return 404 (not 403) for resources the user isn't allowed to see?
  • Are sensitive object IDs UUIDs instead of sequential integers?

Run your app with two different test accounts. Try to access Account A's data while logged in as Account B. If you can โ€” you've got an IDOR. Fix it before a bug bounty hunter does. ๐ŸŽฏ

Real-World IDOR Hall of Shame ๐Ÿ†๐Ÿ’€

IDOR has had some legendary appearances:

  • Facebook (2015): Researcher accessed any user's photos by changing a graph API parameter. $10,000 bounty.
  • Uber (2016): Drivers' personal info exposed by incrementing driver IDs in the API.
  • Instagram (2019): Phone numbers and emails of any account leaked via a contact import API that didn't scope results.
  • Australian myGov (2022): Citizens could view other citizens' tax documents with a simple ID swap.

The pattern is always the same: someone trusted the client not to explore. The client explored. ๐Ÿ•ต๏ธ

The Bottom Line ๐ŸŽฏ

IDOR isn't a clever, exotic hack. It's a missing WHERE user_id = ? clause. It's a forgotten authorize() call. It's the assumption that "authenticated = authorized."

The fix is equally simple โ€” just actually check that the authenticated user owns the specific resource they're requesting. Every. Single. Time.

The golden rule of authorization: Trust the session token to identify the user. Trust nothing else the client sends.

Your database doesn't care whose ID is in the URL. Your code needs to care. ๐Ÿ”


Shipping APIs? Connect on LinkedIn โ€” let's talk secure-by-default architecture.

Want to see authorization done right? My GitHub has real projects with proper access control patterns.

P.S. โ€” Go open your app right now and change a URL parameter to someone else's ID. If you get their data, drop everything and fix it. I'll wait. ๐Ÿ‘€

P.P.S. โ€” Sequential integer IDs in a public API are not just an IDOR risk, they also telegraph your growth metrics to competitors. UUID your primary keys, folks. ๐Ÿ˜