# IDOR (Insecure Direct Object Reference): Examples and Fixes in Laravel

> IDOR is the bug where changing an ID in a URL or request shows you someone else's data. It sits under the top OWASP risk, broken access control. Here is how it shows up in Laravel apps and the policy, scoping and testing patterns that close it.

**Author:** Matt King | **Published:** November 3, 2026 | **Category:** Security

---

You are logged in and looking at `/invoices/1042`. You change the URL to `/invoices/1041` and see another company's invoice: their address, line items, totals. That is an **insecure direct object reference (IDOR)**. The application took an identifier straight from the request and used it to load a record, without checking that this user is allowed to see that record.

IDOR is the most common way **broken access control**, OWASP's number one risk (A01), shows up in real applications. It needs no special tools to exploit, just a browser and patience, and automated vulnerability scanners rarely catch it, because the response looks like a perfectly normal page.

## What IDOR looks like in Laravel

Route model binding makes the vulnerable version very short:

```php
Route::get('/invoices/{invoice}', function (Invoice $invoice) {
    return view('invoices.show', compact('invoice'));
})->middleware('auth');
```

`auth` checks that *someone* is logged in. Nothing checks that the invoice belongs to them. The same bug appears in many shapes:

- **API endpoints**: `GET /api/orders/{id}` returning JSON for any ID.
- **Updates and deletes**: `PUT /projects/{project}` that saves without checking ownership. Worse than reading, because an attacker can change or destroy data.
- **IDs in the request body**: a hidden `<input name="team_id">` or a JSON `user_id` field the server trusts. This overlaps with [mass assignment](/blog/laravel-mass-assignment-fillable-guarded).
- **File downloads**: `/download?file=contracts/1042.pdf` or signed-looking paths that are not actually signed.
- **Nested resources**: `/teams/{team}/projects/{project}` where the project is loaded by ID but never checked against the team in the URL.
- **Livewire**: a public `int $postId` property that the browser can change before calling `delete()`.

## Fix 1: authorize every model with a policy

A policy puts the ownership rule in one place:

```php
// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
    return $invoice->team_id === $user->current_team_id;
}

public function update(User $user, Invoice $invoice): bool
{
    return $invoice->team_id === $user->current_team_id
        && $user->hasTeamRole($invoice->team, 'admin');
}
```

Then check it wherever the model is used. On the route:

```php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware(['auth', 'can:view,invoice']);
```

or in the controller:

```php
use Illuminate\Support\Facades\Gate;

public function show(Invoice $invoice)
{
    Gate::authorize('view', $invoice);

    return view('invoices.show', compact('invoice'));
}
```

Since Laravel 11 the base controller no longer includes the `AuthorizesRequests` trait, so `$this->authorize()` only works if you add the trait back; `Gate::authorize()` works everywhere. For a deeper look at gates and policies, see our [Laravel authorization guide](/blog/laravel-authorization-gates-policies).

## Fix 2: load records through the owner

Often simpler and safer: never look up a record globally in the first place.

```php
$invoice = $request->user()->currentTeam->invoices()->findOrFail($id);
```

If the invoice belongs to someone else, the query finds nothing and the user gets a 404. There is no check to forget, because the unsafe query is never written. For nested routes, Laravel can do this for you:

```php
Route::scopeBindings()->group(function () {
    Route::get('/teams/{team}/projects/{project}', [ProjectController::class, 'show']);
});
```

With scoped bindings, `{project}` is resolved through `$team->projects()`, so a project from another team returns 404. You still need to authorize the `{team}` itself.

In multi-tenant apps, a global scope that adds `where team_id = ?` to every query on tenant models is a strong safety net, as long as you remember that `withoutGlobalScopes()` and raw queries bypass it.

## Fix 3: never trust IDs in the body

Set ownership fields on the server, not from the request:

```php
$project = $request->user()->currentTeam->projects()->create(
    $request->safe()->only(['name', 'description'])
);
```

`team_id` comes from the relationship, not from a form field an attacker can edit. In Livewire, mark ID properties with `#[Locked]` so the browser cannot change them, and authorize again inside every action method.

## Do UUIDs fix IDOR?

No. Random identifiers (UUIDs, ULIDs) stop an attacker *enumerating* `1041, 1040, 1039…`, which is worth having. But IDs leak: they appear in shared links, emails, browser history, Referer headers, logs and other API responses. Treat unguessable IDs as defence in depth, and keep the authorization check.

## Test it like an attacker

IDOR is easy to test and easy to regress. For every resource, add a feature test that logs in as a *different* user:

```php
public function test_users_cannot_view_another_teams_invoice(): void
{
    $invoice = Invoice::factory()->create();
    $intruder = User::factory()->withPersonalTeam()->create();

    $this->actingAs($intruder)
        ->get("/invoices/{$invoice->id}")
        ->assertNotFound(); // or assertForbidden()
}
```

Do the same for update, delete and download routes, and for API endpoints with a second user's token. Returning 404 rather than 403 avoids confirming the record exists.

## Where it fits with the rest of your security

Authorization bugs live inside your application logic, so they are found by tests, code review and penetration testing rather than by external scanners. External checks cover the other half: exposed `.env` files, debug mode, leaked tooling and missing headers that make an IDOR easier to find and exploit. [Run a free StackShield scan](/free-scan) for that half, and read [how to pentest a Laravel application](/blog/how-to-pentest-laravel-application) for a structured way to hunt access-control bugs yourself.

---

## Frequently Asked Questions

### What is an IDOR vulnerability?

An insecure direct object reference is when an application uses an identifier from the request, such as an ID in the URL, to fetch a record without checking that the current user is allowed to access it. Changing the ID exposes or modifies other users' data.

### Is IDOR in the OWASP Top 10?

Yes, as part of A01 Broken Access Control, the highest-ranked category in the OWASP Top 10.

### Do UUIDs prevent IDOR?

No. Random IDs make guessing harder, but IDs leak through URLs, logs, emails, referrers and API responses. Access must still be checked on every request. UUIDs or ULIDs are a useful extra layer, not the fix.

### How do I prevent IDOR in Laravel?

Authorize every model you load from user input with a policy (Gate::authorize, the can middleware or $user->can), or load records through the owner relationship, such as $request->user()->invoices()->findOrFail($id). Add feature tests that log in as a second user and expect 403 or 404.

### Should an IDOR attempt return 403 or 404?

Returning 404 avoids confirming that the record exists. Scoped queries do this naturally. A 403 is fine where existence is not sensitive.

