Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix missing object-level authorization (IDOR) in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Build one query that holds the rule, from $request->user()'s tenant and role, never from request data: Invoice::where('tenant_id', $user->tenant_id), narrowed to owner_id for members. Start every detail, list and export query from it. Answer 404 when it finds nothing, and refuse an export when any requested ID is out of scope.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (Eloquent over SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 53 KB
On this page

Steps

  1. List every route, controller, Livewire component, job and console command that reads an invoice: detail, list, search, export and download. Note where each gets the IDs and the user.
  2. Take the user only from $request->user() (the session or token guard) and its tenant and role from your own tables. Delete code that reads a user, tenant or role from the query string, the body or headers.
  3. Write one method that returns the query the user may read (here visibleTo($user)), or an Eloquent local scope such as Invoice::visibleTo($user), and start every query from it: ->find($id), ->orderBy(...), ->whereIn('id', $ids).
  4. For a single object, ->find($id) on the scoped query (or findOrFail(), which throws ModelNotFoundException and becomes a 404) makes missing and not yours look the same.
  5. Keep policies for actions (update, delete, approve) and call Gate::authorize() or the #[Authorize] controller attribute, but give the policy the same tenant check; for lists and exports the scoped query is the control.
  6. For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
  7. Add regression tests that sign in (actingAs()) as a colleague, as a member of another tenant and as a billing admin of another tenant, and replay invoice IDs against the detail, list and export routes.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/Invoices/VulnerableInvoiceAccess.phpPHP
public function get(User $user, int $id): ?Invoice
{
    return Invoice::find($id);
}

public function list(User $user): Collection
{
    return Invoice::where('tenant_id', $user->tenant_id)->orderBy('id')->get();
}

public function export(User $user, array $ids): Collection
{
    return Invoice::whereIn('id', $ids)->orderBy('id')->get();
}

With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).

The fix

Fixedapp/Invoices/FixedInvoiceAccess.phpPHP
/**
 * Members read their own invoices; billing admins read every invoice in their own tenant.
 * The tenant filter applies to everyone, admins included.
 *
 * @return Builder<Invoice>
 */
public static function visibleTo(User $user): Builder
{
    $invoices = Invoice::query()->where('tenant_id', $user->tenant_id);
    if ($user->role !== 'billing_admin') {
        $invoices->where('owner_id', $user->username);
    }

    return $invoices;
}

public function get(User $user, int $id): ?Invoice
{
    return self::visibleTo($user)->find($id);
}

public function list(User $user): Collection
{
    return self::visibleTo($user)->orderBy('id')->get();
}

public function export(User $user, array $ids): Collection
{
    return self::visibleTo($user)->whereIn('id', $ids)->orderBy('id')->get();
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fixapp/Invoices/WrongInvoiceAccess.phpPHP
private function canRead(User $user, Invoice $invoice): bool
{
    return $invoice->owner_id === $user->username || $user->role === 'billing_admin';
}

public function get(User $user, int $id): ?Invoice
{
    $invoice = Invoice::find($id);

    return $invoice !== null && $this->canRead($user, $invoice) ? $invoice : null;
}

public function list(User $user): Collection
{
    return Invoice::orderBy('id')->get()->filter(fn (Invoice $invoice) => $this->canRead($user, $invoice))->values();
}

public function export(User $user, array $ids): Collection
{
    return Invoice::whereIn('id', $ids)->orderBy('id')->get()
        ->filter(fn (Invoice $invoice) => $this->canRead($user, $invoice))->values();
}

With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).

Routes: the user comes from the session, not the request

app/Http/Controllers/InvoiceController.phpPHP
// The auth middleware answers 401 before any of these run; $request->user() comes from the
// session guard, and the user's tenant and role from the users table, never from the request.

public function index(Request $request): JsonResponse
{
    return response()->json($this->access->list($request->user()));
}

public function show(Request $request, string $id): JsonResponse
{
    $invoice = (int) $id <= self::MAX_ID ? $this->access->get($request->user(), (int) $id) : null;

    // Missing and not yours get the same answer, so IDs cannot be probed.
    return $invoice !== null ? response()->json($invoice) : $this->notFound();
}

public function export(Request $request): Response|JsonResponse
{
    $ids = self::parseIds($request->query('ids'));
    if ($ids === null) {
        return response()->json(['error' => 'ids must be 1 to '.self::MAX_EXPORT_IDS.' invoice IDs, comma-separated'], 400);
    }
    $invoices = $this->access->export($request->user(), $ids);
    // All or nothing: one missing or unreadable ID refuses the whole export, so a partial file
    // is never mistaken for the full one.
    if ($invoices->count() !== count($ids)) {
        return $this->notFound();
    }

    return response(self::toCsv($invoices->all()), 200, ['Content-Type' => 'text/csv; charset=UTF-8']);
}

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/InvoicesTest.phpPHP
// Exploit: other users, other tenants and other tenants' admins must get nothing.

/** Denied looks exactly like missing: 404 and none of the invoice's data. */
private function assertHidden(string $user, string $path, string $number): void
{
    $response = $this->getAs($user, $path);
    $body = $response->getContent();
    $this->assertSame(404, $response->status(), "expected 404, got {$response->status()}: $body");
    $this->assertStringNotContainsString($number, $body, "response leaked $number");
}

public function test_exploit_member_cannot_read_a_colleagues_invoice_in_the_same_tenant(): void
{
    $this->assertHidden('alice', '/invoices/502', 'INV-502');
}

public function test_exploit_member_of_another_tenant_cannot_read_the_invoice(): void
{
    $this->assertHidden('erin', '/invoices/501', 'INV-501');
}

public function test_exploit_billing_admin_cannot_read_an_invoice_in_another_tenant(): void
{
    $this->assertHidden('cara', '/invoices/601', 'INV-601');
}

public function test_exploit_members_list_leaves_out_colleagues_invoices(): void
{
    $this->assertSame([501, 503], $this->listedIds('alice'));
}

public function test_exploit_billing_admins_list_stays_inside_their_tenant(): void
{
    $this->assertSame([501, 502, 503], $this->listedIds('cara'));
}

public function test_exploit_export_that_includes_a_colleagues_invoice_is_refused_whole(): void
{
    $this->assertHidden('alice', '/invoices/export?ids=501,502', 'INV-502');
}

public function test_exploit_billing_admins_export_into_another_tenant_is_refused_whole(): void
{
    $this->assertHidden('cara', '/invoices/export?ids=502,601', 'INV-601');
}

Pitfalls

  • $invoice->owner_id === $user->username || $user->role === 'billing_admin' lets a billing admin of any tenant read every tenant's invoices. That is this card's wrong implementation, written the way an InvoicePolicy::view() method often is, and every test inside one tenant passes against it.
  • Implicit route model binding (Route::get('/invoices/{invoice}', fn (Invoice $invoice) => ...)) loads the row by its key alone, like Invoice::find($id). Scoped bindings (/users/{user}/posts/{post:slug}) only check that the child belongs to the parent in the URL, not to the signed-in user.
  • The auth middleware only checks that someone is signed in; it says nothing about which rows they may read.
  • A policy check on the detail route does nothing for the list and export routes; Laravel policies authorize one action on one model, they do not filter queries.
  • A failed Gate::authorize() is a 403, which tells a prober the invoice exists. Return Response::denyAsNotFound() from the policy, or use the scoped query, so the answer is 404 either way.
  • Filtering in PHP after an unscoped query (Invoice::all()->filter(...)) loads other tenants' data and breaks pagination and counts. Put the scope in the query.
  • A global scope (#[ScopedBy] or addGlobalScope()) keyed on the signed-in tenant is a good second layer, but withoutGlobalScopes() and raw DB::table() queries skip it.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/php-laravel does this for you in the pinned composer image, so it needs Docker).
  2. Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
  3. Do not expect a scanner to find this: Semgrep's PHP rules and Psalm's taint analysis reported nothing on the vulnerable code. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing object-level authorization (IDOR) in PHP and Laravel
ToolRuleWhat it found here
Semgrepr/php (every PHP rule in the Semgrep registry, 47 rules)No findings on any implementation: the vulnerable, fixed and wrong queries look the same to every PHP rule in the registry, and none of them models object-level access.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config bootstrap
Psalmtaint analysis (all taint kinds)Psalm taint analysis with the Laravel pluginNo issues. An invoice ID from the URL reaching Invoice::find() is not a taint sink, so Psalm says nothing about whose invoice it is.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

No rule checked here targets object-level authorization in Laravel. CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (Eloquent over SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authorization/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 53 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
Test results
fixed 15/15 pass; vulnerable 6/15 fail (6 exploit tests); wrong 3/15 fail (3 exploit tests), each test run in a container with --network=none after composer install
Dependency freshness
Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
  2. OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  3. OWASP Multi-Tenant Security Cheat Sheet
  4. Laravel 13.x: Authorizing actions using policies
  5. Laravel 13.x: Customizing the HTTP response status of a policy (denyAsNotFound)
  6. Laravel 13.x: Local scopes
  7. Laravel 13.x: Implicit route model binding
  8. Laravel 13.x: Testing, acting as an authenticated user

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.