Web
IDOR and authorization flaws: viewing another user's data
Can someone open another user's report by changing a number in the URL? What IDOR is, how to find it and how to close it with policies in Laravel.
· 8 min read

Imagine a user opens their invoice at /invoices/1042. Then they change 1042 to 1041 in the address bar and see another organization's invoice. This is IDOR (Insecure Direct Object Reference): accessing an object directly, without checking who owns it.
IDOR is the most common form of A01 "Broken Access Control", the category ranked first in the OWASP Top 10 (2021). CWE describes it as CWE-639 (Authorization Bypass Through User-Controlled Key). Automated scanners almost never find it, because a scanner does not know which record belongs to whom.
Authentication and authorization are different things
Many of these bugs come from mixing up two ideas:
- Authentication asks "who are you?" Is the user logged in.
- Authorization asks "are you allowed to do this?" Can this particular user view or change this particular record.
The auth middleware answers only the first question. Without the second check, any logged-in user can send a request with any ID.
Where IDOR shows up
- Identifiers in URLs:
/orders/15,/users/7/edit,/reports/2026-09.pdf?id=88. - API requests:
GET /api/documents/301,DELETE /api/comments/55. - Hidden form fields:
<input type="hidden" name="user_id" value="7">. - File downloads:
/download?file=contract_15.pdf. - An
organization_idparameter in multi-tenant systems. - The return page after a payment:
/payment/result?order=124.
The last one is overlooked especially often. In our post on building a correct payment flow, we specifically pointed out that the return page must open only for the owner of the order.
A flawed Laravel example
public function show($id)
{
$invoice = Invoice::findOrFail($id);
return Inertia::render('Invoices/Show', ['invoice' => $invoice]);
}
The route may sit inside the auth middleware, but this code never checks who the record belongs to.
The right fix: a policy
Laravel uses policies to keep authorization logic in one place:
php artisan make:policy InvoicePolicy --model=Invoice
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $invoice->organization_id === $user->current_organization_id;
}
public function update(User $user, Invoice $invoice): bool
{
return $this->view($user, $invoice)
&& $user->hasRole('accountant')
&& $invoice->status === 'draft';
}
}
In the controller:
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return Inertia::render('Invoices/Show', ['invoice' => $invoice]);
}
Or at the route level:
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
->can('view', 'invoice');
Note that the update method checks the role and the record's status as well as ownership. Authorization is not only "whose is this?" but also "can this action be performed in this state?"
Another layer: scope the query to the owner
For lists and searches, instead of checking each record with a policy, restrict the query itself:
$invoices = $request->user()->currentOrganization
->invoices()
->latest()
->paginate(20);
Records from other organizations are then never loaded from the database. The same approach works for a single record: ->invoices()->findOrFail($id) returns 404 for a foreign ID.
The rule: any identifier coming from the user is only a request. Every time, the server checks for itself: "does this record belong to this user, and is this action allowed?"
Common mistakes
- Hiding things only in the UI. Even if a button or link is not shown, the request can be sent by hand.
- Protecting only reads.
showis checked, whileupdateanddestroyare forgotten. - Mass assignment.
$invoice->update($request->all())may let a user changeorganization_idoruser_id. Define$fillableexplicitly and use validated data. - Relying on UUIDs. Unguessable IDs help, but they do not replace authorization: an ID can leak from somewhere else (a log, a link, an export).
- Nested objects. In
/projects/5/tasks/90, the project is checked, but nobody checks that the task belongs to that project.
How to test for it
The most effective method is manual testing with two test accounts:
- Create two users, ideally in different organizations.
- Create records with the first account and note their IDs.
- With the second account, send
GET,PUTandDELETErequests to those IDs. - Every response should be 403 or 404, never 200.
- Turn the permission checks into automated tests so future changes do not break them:
it('does not show another organization\'s invoice', function () {
$invoice = Invoice::factory()->create();
$stranger = User::factory()->create();
$this->actingAs($stranger)
->get("/invoices/{$invoice->id}")
->assertForbidden();
});
Summary
IDOR is technically simple, but finding it means going through every route and looking for its ownership check. On a large project that takes a lot of time. In our process, the Claude AI agent quickly reviews all routes, controllers and policies and flags the places without checks, and the expert tests them in practice with two accounts and fixes them.
If you want to be sure your project leaves no path to other users' data, request a security scan. We will help you close authorization gaps before the official expertise.


