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.
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 JSONuser_idfield the server trusts. This overlaps with mass assignment. - File downloads:
/download?file=contracts/1042.pdfor 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 $postIdproperty that the browser can change before callingdelete().
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.
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.
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.
Related Articles
StackShield Now Has an MCP Server: Run Scans and Triage Issues From ChatGPT, Claude and Cursor
Connect StackShield to any MCP-compatible assistant and ask it to scan a domain, explain a failed test, resolve the issues you have fixed, or write a security report. Included on every plan.
SecurityComposer Says Packages "Were Not Loaded Because They Are Affected by Security Advisories": What to Do
Since Composer 2.9, composer update refuses to install package versions with known security advisories. Here is what the error means, how to fix it properly, and how to ignore a specific advisory with a recorded reason when you genuinely are not affected.
SecurityLaravel 12 vs 13: What Actually Changed, and When You Need to Upgrade
Laravel 13 is a small upgrade with a few changes that matter: PHP 8.3, origin-aware CSRF protection, hardened cache unserialization and JSON session serialization. Here is the side-by-side, the support dates, and the upgrade items that can log your users out.
Compare StackShield
Security Checklists
Laravel Production Deployment Security Checklist
A comprehensive security checklist for deploying Laravel applications to production. Covers environment config, server hardening, access control, and monitoring.
20 itemsLaravel API Security Checklist
Secure your Laravel API endpoints against common vulnerabilities. Covers authentication, input validation, rate limiting, and response security.
Stay Updated on Laravel Security
Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.