Security 9 min read Markdown

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.

Matt King
Matt King
November 3, 2026
Last updated: November 3, 2026
IDOR (Insecure Direct Object Reference): Examples and Fixes in Laravel

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:

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.
  • 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:

// 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:

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

or in the controller:

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.

Fix 2: load records through the owner

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

$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:

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:

$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:

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 for that half, and read how to pentest a Laravel application for a structured way to hunt access-control bugs yourself.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue, and most teams do not find out until something breaks. The free scan checks your live app in 60 seconds. Then StackShield re-runs every check after each deploy, so a fix you ship today does not quietly regress next week.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup for the scan. Continuous monitoring on a 14-day trial, no card.

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.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.