0x55aa
← Back to Blog

IDOR: The Vulnerability That's Literally Just Changing a Number in the URL šŸ”¢šŸšØ

•7 min read

IDOR: The Vulnerability That's Literally Just Changing a Number in the URL šŸ”¢šŸšØ

True story from the bug bounty world: A researcher logged into a popular e-commerce platform, went to their profile page, and noticed the URL was /account/orders?user_id=58291. On a whim, they changed it to user_id=58292. And there it was — a complete stranger's order history, address, phone number, and payment method. Full data. Zero resistance.

That one URL parameter change was worth $5,000 in their pocket.

The vulnerability? IDOR — Insecure Direct Object Reference. And it might be sitting in YOUR app right now.

What Is IDOR? šŸ¤”

IDOR happens when your application exposes a reference to an internal object (a database ID, a filename, an account number) and doesn't verify that the requesting user is actually allowed to access it.

# You're user 123. This works:
GET /api/invoices/456

# You change the number. This ALSO works (uh oh):
GET /api/invoices/457  ← Someone else's invoice!

That's it. That's the whole attack. No exploits, no buffer overflows, no zero-days. Just... changing a number.

OWASP has it in the Top 10 under "Broken Access Control" (#1 as of 2021) because it's everywhere and it's devastating. If your API hands out sequential IDs and doesn't check ownership, you've got IDOR.

Why Developers Keep Shipping It 😬

Here's the painful truth: IDOR happens because authorization feels solved by authentication. The thinking goes:

"Only logged-in users can call this endpoint. āœ… So it's fine, right?"

Wrong. Authentication says who you are. Authorization says what you're allowed to do. They're completely different problems.

Look at this classic Node.js/Express endpoint:

// āŒ THE VULNERABLE VERSION — don't ship this
app.get('/api/documents/:id', authenticateUser, async (req, res) => {
  const doc = await Document.findById(req.params.id);

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

  // We check if the user is logged in (authenticateUser middleware)
  // But we NEVER check if this doc belongs to them!
  return res.json(doc);
});

The authenticateUser middleware gives developers a false sense of security. The user IS authenticated. But they can access ANY document by just guessing or incrementing the ID.

The fix is one extra line:

// āœ… THE FIXED VERSION
app.get('/api/documents/:id', authenticateUser, async (req, res) => {
  const doc = await Document.findById(req.params.id);

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

  // ← THIS is the authorization check that was missing
  if (doc.userId !== req.user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  return res.json(doc);
});

Return 403 Forbidden, not 404 Not Found. If you return 404 for records that exist but don't belong to the user, you're leaking information about whether IDs exist (an enumeration vulnerability). Return 403 to say "this exists but it's not yours."

Real-World IDOR Hall of Shame šŸ†

These aren't made-up scenarios. IDOR bugs have been found in:

Facebook (2018): An IDOR in their photo management allowed attackers to delete anyone's photos. Bug bounty payout: $10,000.

Instagram: Researchers found IDORs that exposed private story viewers, draft messages, and phone numbers linked to accounts. Multiple payouts in the $1,000–$6,000 range.

Uber: An IDOR let any authenticated Uber user view trip details for ANY other trip by changing the trip UUID. Driver names, pickup locations, routes — all exposed.

Healthcare platforms: This is the scary one. Several telehealth apps had IDORs on patient records. Changing a patient ID in the URL exposed medical histories, prescriptions, and insurance data. HIPAA violation territory. Except nobody reported it responsibly. They just... quietly accessed the data.

IDOR Goes Beyond Just IDs šŸŽÆ

Most developers think IDOR = sequential integer IDs. But it's anywhere you reference an object:

File paths:

GET /download?file=report_2024_user_123.pdf
→ Try: GET /download?file=report_2024_user_124.pdf

UUIDs (yes, even UUIDs):

GET /api/orders/a1b2c3d4-e5f6-...

UUIDs aren't secret! If you log them, pass them in emails, or embed them in HTML, they can be harvested. Never treat UUID as authorization.

Hashed IDs (also not secret):

GET /profile?id=abc123def456

MD5 or SHA hashes of sequential IDs are guessable with a dictionary. This is not access control.

Indirect references that still leak:

GET /api/export?report_type=user_summary&format=csv
# Response includes ALL users' data, not just the requester's

The Right Architecture: Always Scope to the User šŸ”

The golden rule: every query that returns user data should include the current user's ID as a condition.

In Laravel, this is beautiful with Eloquent scoping:

// āŒ BAD: Fetches the document by ID alone
public function show(Document $document)
{
    return response()->json($document);
}

// āœ… GOOD: Scope every query through the authenticated user
public function show(int $id)
{
    $document = auth()->user()->documents()->findOrFail($id);
    return response()->json($document);
}

Notice the difference: auth()->user()->documents()->findOrFail($id) will only find a document if BOTH the ID matches AND it belongs to the current user. If not, it throws a ModelNotFoundException which Laravel automatically converts to a 404 — or you catch it and return 403.

Even better, use Policies to centralize authorization logic:

// app/Policies/DocumentPolicy.php
public function view(User $user, Document $document): bool
{
    return $user->id === $document->user_id;
}

// Then in your controller:
public function show(Document $document)
{
    $this->authorize('view', $document);  // Throws 403 if unauthorized
    return response()->json($document);
}

Now authorization is explicit, testable, and reusable. You're not scattering if ($doc->user_id !== auth()->id()) checks across 50 controllers.

How to Hunt for IDOR in Your Own App šŸ”

Before someone else finds it, find it yourself:

Step 1: List all endpoints that accept an ID parameter. Look for /api/resource/{id}, ?order_id=, ?file=, etc.

Step 2: Create two test accounts. User A and User B. Log in as User A and create some resources (orders, documents, messages).

Step 3: Log in as User B and try to access User A's resources. Use the IDs you saw while logged in as User A.

Step 4: Try with no authentication at all. Some IDORs work even for unauthenticated users.

Step 5: Check batch/bulk endpoints. /api/documents?ids=1,2,3 should filter to only IDs the current user owns — not just validate that the IDs exist.

The IDOR Checklist āœ…

Before shipping any endpoint that returns data by ID:

  • Is the requesting user authenticated? (authenticateUser middleware)
  • Is the requesting user authorized to access this specific record? (ownership check)
  • Are you using framework-level authorization (Policies, Gates, Guards) rather than ad-hoc checks?
  • Does your query scope automatically through the current user? (user()->resources()->find(id))
  • Did you test it manually with two separate accounts?
  • Are UUIDs or hashed IDs being treated as secret (they're not)?
  • Do bulk/export endpoints filter by the current user?
  • Do you return 403 for unauthorized access rather than 404?

The Bottom Line šŸ’”

IDOR is embarrassing because it's so simple. No advanced exploitation technique. No complex payload. Just a different number in a field.

But that simplicity is exactly why it's so common. Developers think about authentication and forget about authorization. Every endpoint is its own trust boundary. The user proved who they are at login — you need to re-verify they're allowed to touch that specific row of data on every single request.

The fix costs you 5 minutes per endpoint. The breach costs you your users' data, regulatory fines, and a very bad day on Hacker News.

Add the authorization check. Scope your queries. Write the policy. Sleep well.


Found an IDOR in your own app? Want to discuss access control patterns? Connect with me on LinkedIn — I'm always up for a good security war story.

Want to see more secure-by-default patterns? Browse the projects on my GitHub. Defense beats offense when you build it in from day one. šŸ›”ļø

Now go audit those endpoints. I'll wait. šŸ•µļøā€ā™‚ļø