dw darren / blog v2026.8
Back to blog
GET /v1/posts/laravel-auth-audit-idor-broken-access-control 200 OK · 14 min read

How I Built a Tool to Catch Laravel's Most Common Security Blind Spot

Laravel Auth Audit

Open your browser. Visit /users/1. Then /users/2. Then /users/3. If your Laravel app returns a profile page for each of those requests, regardless of who is logged in, you have an IDOR vulnerability. Not a theoretical one. A real one, today, in production.

This is Insecure Direct Object Reference, and it sits under the umbrella of Broken Access Control: the number one vulnerability on the OWASP Top 10 for multiple consecutive release cycles. It is also the easiest class of vulnerability to introduce without noticing, because the route looks secure. It is behind auth middleware. There is even a Policy class in the codebase. The problem is that nobody wired the Policy check to this route.

I built laravel-auth-audit to catch exactly this pattern. Here is the problem in detail, why existing tools do not close the gap, and how the package works.

Why auth middleware is not enough

The auth middleware does one thing: it checks that the current request has an authenticated session. If there is no valid session, it redirects to login. That is all it does. It proves who the user is. It says nothing about what that user is allowed to access.

These are two completely separate concerns, and Laravel gives you separate tools for each. Middleware handles authentication. Policies and Gates handle authorisation. The gap between them is where IDOR lives.

A concrete example. A developer adds a route to view an order:

Route::get('/orders/{order}', [OrderController::class, 'show'])
    ->middleware('auth');

The controller resolves the Order model via route-model binding and returns the data:

public function show(Order $order): JsonResponse
{
    return response()->json($order);
}

This looks fine at a glance. The route is behind auth. But any authenticated user can hit /orders/1, /orders/2, /orders/3 and read every order in the system. The fix is one line:

public function show(Order $order): JsonResponse
{
    $this->authorize('view', $order);

    return response()->json($order);
}

One missing line. Invisible in code review unless the reviewer is specifically looking for it. Invisible to static analysis tools that check types. And completely absent from any automated check that ships with Laravel.

Why existing tools miss this

Larastan and PHPStan are excellent. They catch type errors, undefined methods, and property access on nullable types. They do not know what an authorisation check is. A missing $this->authorize() call is not a type error. It is a valid PHP method body that compiles and runs without complaint.

Enlightn covers a broad set of Laravel security and performance checks, but it does not walk the route-to-controller-to-model graph specifically to verify that each route with model binding has a corresponding authorisation check.

Spatie Laravel Permission is a runtime package that gives you tools to build authorisation systems. It does not audit whether you called those tools everywhere you were supposed to.

The gap is specific: nobody checks that the authorisation patterns Laravel ships with were actually applied at every endpoint that needs them. That is the problem laravel-auth-audit solves.

How the detection works

The package uses nikic/php-parser, the same AST library that PHPStan and Rector use internally, to statically parse controller files without booting the application. It walks every route in Laravel's route collection and runs four detection tiers in confidence order, stopping at the first signal it finds.

Tier 1: can: middleware

The simplest and most explicit signal. If the route definition includes can: middleware, the authorisation check is declared at the routing layer:

Route::get('/reports/export', [ReportController::class, 'export'])
    ->middleware(['auth', 'can:view-reports']);

Tier 2: AST parsing of the controller method

The controller file is read from disk and parsed into an AST. The detector looks inside the specific action method for calls to $this->authorize(), Gate::authorize(), Gate::allows(), Gate::inspect(), abort_unless($user->can(...)), $this->authorizeResource() in the constructor, and relationship-scoped retrieval like $request->user()->orders()->findOrFail($id).

It also inspects Form Request type-hints on the method signature. If the Form Request's authorize() method contains only return true;, it is flagged as the bare-true-form-request anti-pattern. If it references $this->route(), it is labelled [instance-scoped], meaning the check is tied to the specific bound model rather than a generic class-level check.

Tier 3: Policy inference from route-model binding

For routes that use route-model binding, the detector checks whether a Policy is registered for the bound model and whether that Policy has a method matching the implied CRUD action. In v2, it also parses the policy method body. A Policy method that exists but never references its model parameter is flagged as instance-blind-policy - the method cannot enforce per-record ownership if it never looks at the record.

Tier 4: custom signals

Authorisation that lives in a custom middleware or a service method is not visible to the static AST pass. The config provides an escape hatch:

// config/auth-audit.php
'custom_signals' => [
    'ensure.team.owner',
    'App\\Services\\TeamAuthService::authorize',
],

Five anti-patterns that look safe but are not

v2 adds a second detection mode alongside positive signals: named anti-patterns. These are patterns that look like authorisation but provide no real protection. Each one has a name that appears in the output so teams can search for it, link it in code review, and track it across sprints.

unscoped-nested-binding

A route with two route-model-bound parameters where the child is not scoped to the parent. Authorising the parent does not prove the child belongs to it.

// flagged: $order could belong to any team
Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show']);

public function show(Team $team, Order $order): Response
{
    $this->authorize('view', $team); // proves you own the team, not that this order is in it
    return response()->json($order);
}
// fixed: ->scopeBindings() makes Laravel enforce the parent-child relationship
Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show'])
    ->scopeBindings();

class-level-check-on-instance-route

authorize() or Gate::* called with Model::class instead of the bound instance. The Policy receives the class string and cannot check ownership of the specific record.

// flagged: policy receives "App\Models\Order", not the $order instance
$this->authorize('update', Order::class);

// fixed: pass the bound instance
$this->authorize('update', $order);

instance-blind-policy

A Policy method that has no model parameter, or has one but never uses it in the method body. The check runs but cannot enforce per-record ownership.

// flagged: checks role, never checks which order is being accessed
public function update(User $user): bool
{
    return $user->isAdmin();
}

// fixed: add the model parameter and use it
public function update(User $user, Order $order): bool
{
    return $order->user_id === $user->id;
}

unbound-identifier

A route with a raw scalar parameter ({id} without type-hinting to a model) where the controller queries without scoping to the authenticated user. Any logged-in user can iterate the identifier.

// flagged: any authenticated user can request any order
public function show(Request $request, int $id): Response
{
    $order = Order::findOrFail($id);
    return response()->json($order);
}

// fixed: scope through the user's relationship
public function show(Request $request, int $id): Response
{
    $order = $request->user()->orders()->findOrFail($id);
    return response()->json($order);
}

discarded-gate-result

Gate::allows(), Gate::check(), or Gate::any() called as a bare statement. The return value is a boolean that tells you whether the action is permitted - if you do not use it, the check has no effect.

// flagged: result discarded, this line does nothing
Gate::allows('delete', $order);

// fixed: use the result
abort_unless(Gate::allows('delete', $order), 403);
// or just: Gate::authorize('delete', $order);

Running it and reading the output

Install as a dev dependency and run:

composer require phoenix1331/laravel-auth-audit --dev
php artisan auth-audit:run

The console output is a table with one row per route, now including anti-pattern names in the Auth Check column:

  Route                          Verb   Auth Check                  Status
  -------------------------------------------------------------------------
  /orders/{order}                PUT    $this->authorize()          authorised
  /teams/{team}/orders/{order}   GET    unscoped-nested-binding     unauthorised
  /invoices/{id}                 GET    unbound-identifier          unauthorised
  /reports/export                GET    can:view-reports            authorised
  /webhooks/stripe               POST   Signature verified          skipped
  /users/{id}                    GET    unbound-identifier          baselined
  -------------------------------------------------------------------------
  Coverage: 82% (211/257 routes)
  18 unauthorised . 28 excluded . 13 skipped . 4 baselined

For CI, set a minimum threshold. The command exits with code 1 when coverage falls below it:

php artisan auth-audit:run --min=90

Adopting v2 on an existing codebase

v2 detection is stricter. Routes that were green in v1 can flip red when the deeper analysis spots a class-level check, a discarded gate result, or an instance-blind policy. On a large codebase, fixing everything before you can ship v2 is not always practical.

The baseline system is the answer. Run it once to record the current state:

php artisan auth-audit:run --generate-baseline

This writes auth-audit-baseline.json at the project root, keyed by route signature. Commit it. Then update your CI command:

php artisan auth-audit:run --compare=auth-audit-baseline.json --min=80

Routes in the baseline appear as baselined and are excluded from the coverage percentage. New routes added after the baseline was generated are not grandfathered in - they must be authorised or they fail the build. As you fix violations, regenerate the baseline to shrink it. The goal is to reach an empty baseline, at which point you remove --compare and enforce full coverage.

If a route in the baseline is removed from your application entirely, the command reports stale entries and prompts you to regenerate. The baseline stays accurate without manual maintenance.

Bypassing without hiding

Some routes genuinely do not need authorisation. A public health check endpoint, a Stripe webhook verified by signature, a marketing landing page. The package provides two bypass mechanisms, and both require a mandatory reason string. Silent suppression is not possible.

use Phoenix1331\LaravelAuthAudit\Attributes\WithoutAuthAudit;

#[WithoutAuthAudit('Signature verified via Stripe webhook secret, not policy-gated')]
public function stripe(): void { ... }

// or on a route definition
Route::get('/up', fn () => response()->json(['ok' => true]))
    ->withoutAuthAudit('Health check endpoint, no sensitive data, no auth required');

The attribute accepts an expires date. Past that date the bypass automatically reverts to a flagged violation - temporary suppressions cannot quietly become permanent technical debt.

#[WithoutAuthAudit('Policy not written yet', expires: '2026-12-31')]
public function betaExport(): void { ... }

What this catches in practice

In a moderately sized Laravel application, a first v2 run typically surfaces:

  • Controller actions where $this->authorize('update', Order::class) was written instead of passing the bound instance - a subtle mistake that passes review because it looks like a real check
  • Nested resource routes (/teams/{team}/orders/{order}) where the developer authorised the parent team but never scoped the child order to it
  • Policy methods that check a user's role without ever looking at which record is being accessed - they prove the user has permission to do the action in general, not on this specific record
  • Legacy scalar-param routes (/invoices/{id}) where Invoice::findOrFail($id) was never replaced with a user-scoped query
  • Gate::allows() calls written by developers who confused it with Gate::authorize() - the former returns a boolean, the latter throws; the bare call does nothing

Summary

Authentication and authorisation are separate problems. Laravel gives you the tools to solve both, but nothing enforces that you used them correctly together. The gap is invisible to type checkers, invisible in code review unless you know what to look for, and invisible at runtime until someone exploits it.

laravel-auth-audit is a Composer dev-dependency that makes the gap visible with a single artisan command. v2 goes further than checking whether a call exists - it checks whether the call actually proves what it is supposed to prove. A policy method that never looks at the model, a gate result that is discarded, a class constant passed where an instance was needed: these all look like security in the diff and silently fail in production.

The package is on Packagist. If you are upgrading from v1, the UPGRADING.md covers every pattern that can flip from green to red and how to adopt incrementally using the baseline system. Reach out via the contact section if you have found it useful or spotted a gap it should cover.